diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 216dd1e..0815dcc 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,10 +2,22 @@ "version": 1, "isRoot": true, "tools": { - "jetbrains.resharper.globaltools": { - "version": "2022.2.4", + "cake.tool": { + "version": "2.2.0", "commands": [ - "jb" + "dotnet-cake" + ] + }, + "nbgv": { + "version": "3.5.119", + "commands": [ + "nbgv" + ] + }, + "dotnet-reportgenerator-globaltool": { + "version": "5.1.12", + "commands": [ + "reportgenerator" ] } } diff --git a/.editorconfig b/.editorconfig index 76ade18..234a04e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,15 +3,16 @@ root = true # Default settings. [*] +charset = utf-8 indent_style = space indent_size = 4 -end_of_line = crlf +end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true # Code files [*.{cs,csx,vb,vbx}] -insert_final_newline = true +end_of_line = crlf charset = utf-8-bom # Xml project files @@ -19,7 +20,7 @@ charset = utf-8-bom indent_size = 2 # Xml config files -[*.{ruleset,config,nuspec,resx,vsixmanifest,vsct}] +[*.{ruleset,config,nuspec,resx,vsixmanifest,vsct,runsettings}] indent_size = 2 # JSON files @@ -32,15 +33,18 @@ indent_size = 2 # Shell scripts [*.sh] -end_of_line = lf indent_size = 2 +# Windows batch files +[*.{bat,cmd}] +end_of_line = crlf + # Markdown files [*.md] -end_of_line = lf indent_size = 2 trim_trailing_whitespace = false # InnoSetup files [*.iss] +end_of_line = crlf indent_size = 2 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..1bea56f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,26 @@ +# This file specifies users whose review is automatically requested when a PR modifies certain files. +# For more ionformation see: +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# GitHub and apps configuration files +/.github/ @rdeago +/.all-contributorsrc @rdeago + +# Git configuration files +/.git* @rdeago + +# Build scripts +*.cake @rdeago + +# Licensing +LICENSE @rdeago +THIRD-PARTY-NOTICES @rdeago + +# Solution infrastructure files +Directory.Build.* @rdeago +.editorconfig @rdeago +.globalconfig @rdeago +stylecop.json @rdeago +*.DotSettings @rdeago +NuGet.config @rdeago +version.json @rdeago diff --git a/.github/ISSUE_TEMPLATE/01_bug_report.md b/.github/ISSUE_TEMPLATE/01_bug_report.md deleted file mode 100644 index ed39096..0000000 --- a/.github/ISSUE_TEMPLATE/01_bug_report.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: 'bug' -assignees: '' ---- - - - -## Current version: - -## Description - - - -## Configuration - - - -## Regression? - - - -## Other information - - diff --git a/.github/ISSUE_TEMPLATE/02_enhancement_proposal.md b/.github/ISSUE_TEMPLATE/02_enhancement_proposal.md deleted file mode 100644 index 02f09e5..0000000 --- a/.github/ISSUE_TEMPLATE/02_enhancement_proposal.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -name: Enhancement proposal -about: Propose an improvement or new functionality -title: '' -labels: 'enhancement' -assignees: '' ---- - -## Current version: - -## Background and motivation - - - -## Proposed enhancement - - - -## Implementation proposals - - - -## Usage examples - - - -## Risks - - diff --git a/.github/ISSUE_TEMPLATE/03_blank_issue.md b/.github/ISSUE_TEMPLATE/03_blank_issue.md deleted file mode 100644 index b0c189e..0000000 --- a/.github/ISSUE_TEMPLATE/03_blank_issue.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: Blank issue -about: Something that doesn't fit the other categories -title: '' -labels: '' -assignees: '' ---- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 3ba13e0..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1 +0,0 @@ -blank_issues_enabled: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index e8229b4..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,52 +0,0 @@ -## Types of changes - - - -- [ ] Bugfix -- [ ] Enhancement (new and/or improved behavior) -- [ ] Refactoring (no functional changes) -- [ ] Code style update (no functional changes) -- [ ] Dependencies added, updated, or removed -- [ ] Build related changes -- [ ] Repository infrastructure changes (e.g. changes to issue / PR templates) -- [ ] CI related changes -- [ ] Documentation update -- [ ] Other (please describe below) - -## Proposed changes / fixed issues - - - -Closes # -Partial fix for # - -## Checklist - - - -- [ ] My contribution is my original work -- [ ] My contribution, or part of it, comes from projects with an MIT-compatible license AND I have updated the THIRD-PARTY-NOTICES file accordingly -- [ ] I have added and/or updated related documentation (if applicable) -- [ ] I have updated the "Unreleased changes" section in [CHANGELOG.md](https://github.com/Buildvana/Buildvana.Sdk/blob/main/CHANGELOG.md) according to the modifications I made - -## Other information - - diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e875327..28791ea 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,10 +3,10 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: { interval: "daily" } - labels: [ "area-deps", "area-ci" ] + labels: [ "area:deps", "area:ci" ] reviewers: [ "rdeago" ] - package-ecosystem: "nuget" directory: "/" schedule: { interval: "daily" } - labels: [ "area-deps" ] + labels: [ "area:deps" ] reviewers: [ "rdeago" ] diff --git a/.github/labeler.yml b/.github/labeler.yml index 0d91778..4cc1242 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,60 +1,39 @@ -area-core: - - "src/Buildvana.Sdk/Sdk/**/*" - -area-assembly_signing: - - "src/Buildvana.Sdk/Modules/AssemblySigning/**/*" - - "src/Buildvana.Sdk/Tasks/AssemblySigning/**/*" - -area-jetbrains_annotations: - - "src/Buildvana.Sdk/Modules/JetBrainsAnnotations/**/*" - - "src/Buildvana.Sdk/Tasks/JetBrainsAnnotations/**/*" - -area-literal_assembly_attributes: - - "src/Buildvana.Sdk/Modules/LiteralAssemblyAttributes/**/*" - - "src/Buildvana.Sdk/Tasks/LiteralAssemblyAttributes/**/*" - -area-nuget_pack: - - "src/Buildvana.Sdk/Modules/NuGetPack/**/*" - - "src/Buildvana.Sdk/Tasks/NuGetPack/**/*" - -area-standard_analyzers: - - "src/Buildvana.Sdk/Modules/StandardAnalyzers/**/*" - - "src/Buildvana.Sdk/Tasks/StandardAnalyzers/**/*" - -area-thisassembly_class: - - "src/Buildvana.Sdk/Modules/ThisAssemblyClass/**/*" - - "src/Buildvana.Sdk/Tasks/ThisAssemblyClass/**/*" - -area-version_file: - - "src/Buildvana.Sdk/Modules/VersionFile/**/*" - - "src/Buildvana.Sdk/Tasks/VersionFile/**/*" - -area-xml_documentation: - - "src/Buildvana.Sdk/Modules/XmlDocumentation/**/*" - - "src/Buildvana.Sdk/Tasks/XmlDocumentation/**/*" - -area-build: - - "**/*.sln" - - "**/*.csproj" - - "**/Common.props" - - "**/Common.targets" - -area-ci: - - ".github/workflows/**/*" - - "lgtm.yml" - -area-deps: - - "**/Directory.Packages.props" +'area:code': + - '*.sln' + - 'src/**/*' + +'area:style': + - '**/.editorconfig' + - '**/.globalconfig' + - '**/stylecop.json' + - '**/*.DotSettings' + +'area:build': + - '**/*.cake' + - 'build/**/*' + +'area:repo': + - '.github/*.yml' + - '.github/**/*.md' + - CODEOWNERS + - '.github/CODEOWNERS' + +'area:ci': + - '.github/workflows/**/*' + - 'lgtm.yml' + +'area:deps': + - '**/Directory.Packages.props' - Directory.Build.props - Directory.Build.targets - - "src/Buildvana.Sdk/Sdk/PackageVersions.props" - -area-docs: - - "docs/**/*" - - "**/LICENSE" - - "**/README.md" - - "**/THIRD-PARTY-NOTICES" - -area-repo: - - ".github/*.yml" - - ".github/**/*.md" + - global.json + - .config/dotnet-tools.json + +'area:docs': + - 'docs/**/*' + - '.all-contributorsrc' + - '**/CHANGELOG.md' + - '**/LICENSE' + - '**/NuGet-README.md' + - '**/README.md' + - '**/THIRD-PARTY-NOTICES' diff --git a/.github/labels.json b/.github/labels.json deleted file mode 100644 index f27aaba..0000000 --- a/.github/labels.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { "name": "v1.x", "color": "9c3", "description": "[issue/PR] affects version 1." }, - - { "name": "area-core", "color": "ccc", "description": "[issue/PR] affects SDK core files." }, - { "name": "area-assembly_signing", "color": "ccc", "description": "[issue/PR] affects the AssemblySigning module." }, - { "name": "area-jetbrains_annotations", "color": "ccc", "description": "[issue/PR] affects the JetBrainsAnnotations module." }, - { "name": "area-literal_assembly_attributes", "color": "ccc", "description": "[issue/PR] affects the LiteralAssemblyAttributes module." }, - { "name": "area-nuget_pack", "color": "ccc", "description": "[issue/PR] affects the NuGetPack module." }, - { "name": "area-standard_analyzers", "color": "ccc", "description": "[issue/PR] affects the StandardAnalyzers module." }, - { "name": "area-thisassembly_class", "color": "ccc", "description": "[issue/PR] affects the ThisAssemblyClass module." }, - { "name": "area-version_file", "color": "ccc", "description": "[issue/PR] affects the VersionFile module." }, - { "name": "area-xml_documentation", "color": "ccc", "description": "[issue/PR] affects the XmlDocumentation module." }, - - { "name": "area-build", "color": "ccc", "description": "[issue/PR] affects project files and/or build settings." }, - { "name": "area-repo", "color": "ccc", "description": "[issue/PR] affects repository settings, e.g. issue templates." }, - { "name": "area-deps", "color": "ccc", "description": "[issue/PR] adds, updates, or removes dependencies." }, - { "name": "area-docs", "color": "ccc", "description": "[issue/PR] affects documentation." }, - { "name": "area-ci", "color": "ccc", "description": "[issue/PR] affects CI / CD / actions." }, - - { "name": "bug", "color": "c00", "description": "[issue/PR] raises / solves a bug." }, - { "name": "enhancement", "color": "36c", "description": "[issue/PR] requests / implements new or improved functionality." }, - { "name": "refactor", "color": "069", "description": "[issue/PR] requests / implements a refactor of existing code without affecting functionality." }, - { "name": "style", "color": "cfc", "description": "[PR] contains code style changes." }, - { "name": "design", "color": "cfc", "description": "[issue] Design discussion." }, - - { "name": "breaking", "color": "fc0", "description": "[issue/PR] requires / contains breaking changes." }, - { "name": "help_wanted", "color": "c36", "description": "[issue/PR] needs help to solve / finalize." }, - { "name": "easy", "color": "ccf", "description": "[issue] Easy issue, good pick for new collaborators." }, - - { "name": "invalid", "color": "666", "description": "[issue] Problem caused by user or third-party code, or otherwise not a valid issue." }, - { "name": "duplicate", "color": "666", "description": "[issue] Duplicate of another issue." }, - { "name": "by_design", "color": "666", "description": "[issue] The lamented behavior is by design." }, - { "name": "wontfix", "color": "666", "description": "[issue] Valid issue, but will not be acted upon (see comments)." } -] \ No newline at end of file diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..1f893db --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,17 @@ +changelog: + exclude: + labels: + - ignore-for-release + categories: + - title: Breaking changes + labels: + - breaking + - title: New features + labels: + - enhancement + - title: Bugs fixed + labels: + - bug + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/build-test-pack.yml b/.github/workflows/build-test-pack.yml new file mode 100644 index 0000000..4925452 --- /dev/null +++ b/.github/workflows/build-test-pack.yml @@ -0,0 +1,40 @@ +name: Build, test, and pack + +on: + push: + branches: [ main, 'v[0-9]+.[0-9]+' ] + pull_request: + branches: [ main, 'v[0-9]+.[0-9]+' ] + +jobs: + build_test_pack: + runs-on: windows-latest + env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + DOTNET_CLI_UI_LANGUAGE: 'en-US' + steps: + - name: Checkout repository with full history + uses: actions/checkout@v3 + with: + fetch-depth: 0 # Checkout with full history so nbgv can compute Git height correctly. + - name: Setup .NET SDK + uses: actions/setup-dotnet@v3 + with: + global-json-file: global.json + - name: Restore .NET tools + shell: cmd + run: dotnet tool restore + - name: Run build script + shell: cmd + run: | + if [%CAKE_VERBOSITY%]==[] set CAKE_VERBOSITY=Normal + if [%RUNNER_DEBUG%]==[1] set CAKE_VERBOSITY=Diagnostic + dotnet cake --target Pack --verbosity %CAKE_VERBOSITY% + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + working-directory: ./TestResults/ + files: Cobertura.xml + verbose: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..07715b2 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,55 @@ +name: "CodeQL" + +on: + push: + branches: [ main, 'v[0-9]+.[0-9]+' ] + pull_request: + branches: [ main, 'v[0-9]+.[0-9]+' ] + schedule: + - cron: '38 16 * * 6' # At 4:38PM, every Saturday + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + DOTNET_CLI_UI_LANGUAGE: 'en-US' + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [ 'csharp' ] # https://aka.ms/codeql-docs/language-support + steps: + - name: Checkout repository with full history + uses: actions/checkout@v3 + with: + fetch-depth: 0 # Checkout with full history so nbgv can compute Git height correctly. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + - name: Setup .NET SDK + uses: actions/setup-dotnet@v3 + with: + global-json-file: global.json + - name: Restore .NET tools + run: dotnet tool restore + - name: Build solution + run: | + if test "$RUNNER_DEBUG" == "1"; then + CAKE_VERBOSITY="Diagnostic" + elif test -z "$CAKE_VERBOSITY"; then + CAKE_VERBOSITY="Normal" + fi + dotnet cake --target Build --verbosity $CAKE_VERBOSITY + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml deleted file mode 100644 index 1cc3257..0000000 --- a/.github/workflows/continuous-integration.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Continuous integration - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -env: - ContinuousIntegrationBuild: "true" - DOTNET_NOLOGO: "true" - DOTNET_CLI_UI_LANGUAGE: "en-US" - -jobs: - - build: - name: Build and pack on CI server - strategy: - fail-fast: false - matrix: - os: [ ubuntu-latest, windows-latest ] - runs-on: ${{ matrix.os }} - steps: - - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v3 - with: - global-json-file: global.json - - - name: Restore dependencies - run: dotnet restore - - - name: Build solution - run: dotnet build -c Release --no-restore /maxCpuCount:1 - - - name: Run unit tests - run: dotnet test -c Release --no-build - - - name: Prepare for distribution - run: dotnet pack -c Release --no-build /maxCpuCount:1 diff --git a/.github/workflows/define-labels.yml b/.github/workflows/define-labels.yml deleted file mode 100644 index c2c5a8c..0000000 --- a/.github/workflows/define-labels.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Define issue labels -on: issues - -jobs: - - labels: - - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v3 - - - name: Update repository labels - uses: lannonbr/issue-label-manager-action@3.0.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml index 4dbb294..d33c312 100644 --- a/.github/workflows/label-pull-requests.yml +++ b/.github/workflows/label-pull-requests.yml @@ -7,16 +7,12 @@ jobs: set_labels: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - - name: Checkout uses: actions/checkout@v3 - - - name: Update repository labels - uses: lannonbr/issue-label-manager-action@3.0.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Assign labels to pull request uses: actions/labeler@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a7717f1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Publish a release + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +on: + workflow_dispatch: + inputs: + versionSpecChange: + description: 'Version spec change' + required: true + type: choice + default: 'None' + options: + - None + - Unstable + - Stable + - Minor + - Major + checkPublicApi: + description: 'Check public API' + required: false + default: true + type: boolean + checkChangelog: + description: 'Check changelog' + required: false + default: true + type: boolean + cakeVerbosity: + description: 'Cake verbosity' + required: true + type: choice + default: 'Normal' + options: + - Normal + - Verbose + - Diagnostic +jobs: + release: + runs-on: windows-latest + env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + DOTNET_CLI_UI_LANGUAGE: 'en-US' + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + PRERELEASE_NUGET_SOURCE: 'https://www.myget.org/F/tenacom-preview/api/v3/index.json' + PRERELEASE_NUGET_KEY: ${{ secrets.MYGET_DEPLOYMENT_KEY }} + RELEASE_NUGET_SOURCE: 'https://api.nuget.org/v3/index.json' + RELEASE_NUGET_KEY: ${{ secrets.NUGET_DEPLOYMENT_KEY }} + VERSION_SPEC_CHANGE: ${{ inputs.versionSpecChange }} + CHECK_PUBLIC_API: ${{ inputs.checkPublicApi }} + CHECK_CHANGELOG: ${{ inputs.checkChangelog }} + CAKE_VERBOSITY: ${{ inputs.cakeVerbosity }} + steps: + - name: Log workflow inputs + shell: cmd + run: | + echo Version spec change : %VERSION_SPEC_CHANGE% + echo Check public API : %CHECK_PUBLIC_API% + echo Check changelog : %CHECK_CHANGELOG% + echo Cake verbosity : %CAKE_VERBOSITY% + - name: Checkout repository with full history + uses: actions/checkout@v3 + with: + fetch-depth: 0 # Checkout with full history so nbgv can compute Git height correctly. + token: ${{ secrets.RELEASE_TOKEN }} + persist-credentials: true # We need auth set up in the Cake script + - name: Setup .NET SDK + uses: actions/setup-dotnet@v3 + with: + global-json-file: global.json + - name: Restore .NET tools + shell: cmd + run: dotnet tool restore + - name: Run build script + id: build + shell: cmd + run: | + if [%CAKE_VERBOSITY%]==[] set CAKE_VERBOSITY=Normal + if [%RUNNER_DEBUG%]==[1] set CAKE_VERBOSITY=Diagnostic + dotnet cake --target Release --verbosity %CAKE_VERBOSITY% + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + working-directory: ./TestResults/ + files: Cobertura.xml + name: v${{ steps.build.outputs.version }} + verbose: true diff --git a/.gitignore b/.gitignore index e1a82c6..530fbbf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,11 @@ artifacts/ # Local build logs logs/ -# Folders created by CI -.coverage/ +# Cake build +tools/ + +# Testing +TestResults/ # Visual Studio **/bin/ @@ -20,3 +23,10 @@ _ReSharper.*/ # Windows thumbnails Thumbs.db + +# DocFx +docs/globalMetadata.json +docs/api/.manifest +docs/api/*.yml +docs/obj/ +docs/_site/ diff --git a/Buildvana.Sdk.sln b/Buildvana.Sdk.sln index a2da57e..0676992 100644 --- a/Buildvana.Sdk.sln +++ b/Buildvana.Sdk.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.0.32014.148 +VisualStudioVersion = 17.4.33103.184 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Buildvana.Sdk", "src\Buildvana.Sdk\Buildvana.Sdk.csproj", "{3D872329-6B84-4739-B727-1AED462DFD5A}" EndProject @@ -14,11 +14,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "- Configuration", "- Config Common.props = Common.props Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets + NuGet.config = NuGet.config + stylecop.json = stylecop.json + version.json = version.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "- Dependencies", "- Dependencies", "{C06EF000-C1AC-4661-B818-4795D37C5E8D}" + ProjectSection(SolutionItems) = preProject Directory.Packages.props = Directory.Packages.props + .config\dotnet-tools.json = .config\dotnet-tools.json global.json = global.json - nuget.config = nuget.config - stylecop.json = stylecop.json - VERSION = VERSION EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "- Documentation", "- Documentation", "{F2FA2D74-645C-4F7A-A60D-EE2355E5CBA1}" diff --git a/Buildvana.Sdk.sln.DotSettings b/Buildvana.Sdk.sln.DotSettings index 0a8332b..bbe708e 100644 --- a/Buildvana.Sdk.sln.DotSettings +++ b/Buildvana.Sdk.sln.DotSettings @@ -1,7 +1,6 @@  None NotCompiledCode - True DoShow DoShow @@ -426,14 +425,9 @@ WARNING Experimental True - - - True False - True - - - + False + OPTIMAL_FILL True END_OF_LINE 1 @@ -449,10 +443,10 @@ 0 False True - True - True + True NEVER NEVER + True False NEVER False @@ -490,7 +484,6 @@ System.CodeDom.Compiler.GeneratedCodeAttribute <data><AttributeFilter ClassMask="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" IsEnabled="True" /><AttributeFilter ClassMask="System.CodeDom.Compiler.GeneratedCodeAttribute" IsEnabled="True" /></data> True - True True True @@ -498,6 +491,7 @@ True True True + True True True - + diff --git a/Common.props b/Common.props index b410e05..5ce72a2 100644 --- a/Common.props +++ b/Common.props @@ -1,43 +1,48 @@ - - + Buildvana.Sdk - Riccardo De Agostini and Buildvana contributors - Buildvana - The Buildvana team - Copyright (C) 2020-2022 Riccardo De Agostini and Buildvana contributors. + Tenacom and contributors + Tenacom + Tenacom + Copyright (c) $(Authors) + false + https://github.com/Tenacom/Buildvana.Sdk + See $(PackageProjectUrl)/blob/main/CHANGELOG.md + https://github.com/Tenacom/Buildvana.Sdk + true + true + true + true - - true latest + false + true enable true true true - true true false + true + false - + + + + + false - false false false - - - - true - - diff --git a/Directory.Build.targets b/Directory.Build.targets index 98af971..a3481a2 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,7 +1,4 @@  - - - diff --git a/Directory.Packages.props b/Directory.Packages.props index aac7f59..231b74e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,26 +10,27 @@ The minimum supported version of Roslyn. Used by the SDK to determine whether the compiler in use is supported. --> - 4.3 + 4.4 - .NET SDK v6.0.400+ / Visual Studio 2022 17.3+ + .NET SDK v7.0.100+ / Visual Studio 2022 17.4+ - roslyn4.3 + roslyn4.4 - + + diff --git a/LICENSE b/LICENSE index c482c82..eb57cc5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -MIT License +The MIT License (MIT) -Copyright (C) Riccardo De Agostini and Buildvana contributors. All rights reserved. +Copyright (c) Tenacom and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c5bf510..ae97548 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,32 @@ -# Buildvana SDK +# ![Buildvana SDK](graphics/Readme.png) -Part of [the Buildvana project](https://github.com/Buildvana/Buildvana). +[![License](https://badgen.net/badge/license/MIT/blue)](https://github.com/Tenacom/Buildvana.Sdk/blob/main/LICENSE) +[![Latest release](https://badgen.net/github/release/Tenacom/Buildvana.Sdk?label=latest)](https://github.com/Tenacom/Buildvana.Sdk/releases) +[![Latest stable release](https://badgen.net/github/release/Tenacom/Buildvana.Sdk/stable?label=stable)](https://github.com/Tenacom/Buildvana.Sdk/releases) +[![Changelog](https://badgen.net/badge/changelog/Keep%20a%20Changelog%20v1.0.0/orange)](https://github.com/Tenacom/Buildvana.Sdk/blob/main/CHANGELOG.md) -[![License](https://img.shields.io/github/license/Buildvana/Buildvana.Sdk.svg)](https://github.com/Buildvana/Buildvana.Sdk/blob/main/LICENSE) -[![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/Buildvana/Buildvana.Sdk?include_prereleases)](https://github.com/Buildvana/Buildvana.Sdk/releases) -[![Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog%20v1.0.0-%23E05735)](https://github.com/Buildvana/Buildvana.Sdk/blob/main/CHANGELOG.md) +[![Build, test, and pack](https://github.com/Tenacom/Buildvana.Sdk/actions/workflows/build-test-pack.yml/badge.svg)](https://github.com/Tenacom/Buildvana.Sdk/actions/workflows/build-test-pack.yml) +[![CodeQL](https://github.com/Tenacom/Buildvana.Sdk/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/Tenacom/Buildvana.Sdk/actions/workflows/codeql-analysis.yml) -[![CodeFactor](https://www.codefactor.io/repository/github/buildvana/buildvana.sdk/badge)](https://www.codefactor.io/repository/github/buildvana/buildvana.sdk) -[![Last commit](https://img.shields.io/github/last-commit/Buildvana/Buildvana.Sdk.svg)](https://github.com/Buildvana/Buildvana.Sdk/commits/main) -[![Open issues](https://img.shields.io/github/issues-raw/Buildvana/Buildvana.Sdk.svg?label=open+issues)](https://github.com/Buildvana/Buildvana.Sdk/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) -[![Closed issues](https://img.shields.io/github/issues-closed-raw/Buildvana/Buildvana.Sdk.svg?label=closed+issues)](https://github.com/Buildvana/Buildvana.Sdk/issues?q=is%3Aissue+is%3Aclosed+sort%3Aupdated-desc) +![Repobeats analytics image](https://repobeats.axiom.co/api/embed/733fff6e0c96c981b6229b450fdf4df3e1b4e584.svg "Repobeats analytics image") -[![Slack](https://img.shields.io/badge/join_us-on_Slack-ff7fc0.svg?logo=slack)](https://join.slack.com/t/buildvana/shared_invite/zt-e667rvy8-hCtADFiuF8OuiYvthIiWVw) +| Latest packages | NuGet | MyGet | +|-----------------|-------|-------| +| Buildvana.Sdk | [![Buildvana.Sdk @ NuGet](https://badgen.net/nuget/v/Buildvana.Sdk?icon=nuget&label=)](https://nuget.org/packages/Buildvana.Sdk) | [![Buildvana.Sdk @ MyGet](https://img.shields.io/myget/tenacom-preview/vpre/Buildvana.Sdk?label=&color=orange)](https://www.myget.org/feed/tenacom-preview/package/nuget/Buildvana.Sdk) | --- - [At a glance](#at-a-glance) - [Benefits](#benefits) - - [Supported project types](#supported-project-types) - - [Requirements](#requirements) - - [Git repository](#git-repository) + - [Compatibility](#compatibility) + - [Project types](#project-types) + - [Programming languages](#programming-languages) + - [Git servers](#git-servers) - [Toolchain](#toolchain) - [Quick start](#quick-start) - [Contributing](#contributing) -- [Credits](#credits) - ---- - -![Buildvana SDK](https://raw.githubusercontent.com/Buildvana/Buildvana/main/graphics/Buildvana-Readme.png) - -**Buildvana** _/bɪldˈvɑːnə/_ - -1. a transcendent state of the .NET programmer's mind, in which there is neither suffering, confusion, nor waste of time. - -2. a collection of open-source methods and processes to help .NET programmers achieve Buildvana. - -3. a collection of open-source projects supporting programmers that aim to reach Buildvana. +- [Contributors](#contributors) +- [Proudly built using Buildvana SDK](#proudly-built-using-buildvana-sdk) --- @@ -43,61 +34,53 @@ Part of [the Buildvana project](https://github.com/Buildvana/Buildvana). Buildvana SDK is an opinionated, best-practices-based, CI-friendly, VS-friendly, MSBuild-powered build system for .NET projects. -It is part of [the Buildvana project](https://github.com/Buildvana/), which also comprises ready-to-use template repositories that implement automated build, testing, and deployment for .NET projects. - -> **DISCLAIMER:** Buildvana SDK, just as the whole Buildvana project, is still a work in progress. +> **DISCLAIMER:** Buildvana SDK is still a work in progress. Your mileage may vary, if you break your build you own both pieces, and so on and so forth. However, Buildvana SDK has already been used successfully in production, for both business and open source projects. ### Benefits -- Single source of truth for assembly versions -- Single source of truth for package licenses and copyright notices - Helps you keep your project files clean and concise - even better than "plain" MSBuild SDKs +- Single source of truth for assembly versions (via [`Nerdbank.GitVersioning`(https://github.com/dotnet/Nerdbank.GitVersioning)]) +- Single source of truth for package licenses and copyright notices - More auto-generated assembly information (`ClsCompliant`, `COMVisible`) -- Custom auto-generated assembly information - Automatic configuration of commonly-used code analyzers - ... -### Supported project types +### Compatibility + +#### Project types - :heart: Multi-platform / Cross-platform projects - :heart: Libraries - :heart: Console apps - :heart: Windows Forms - :heart: Projects using `Microsoft.Build.NoTargets` SDK -- :heart: [Avalonia](https://avaloniaui.net) (still experimenting, but no problems so far) +- :heart: [Avalonia UI](https://avaloniaui.net) (still experimenting, but no problems so far) - :question: WPF (testers welcome) - :question: [UNO Platform](https://platform.uno) (testers welcome) +- :question: MAUI (testers welcome) - :question: All kinds of Web projects (testers welcome) - :thumbsdown: Legacy (non-SDK) projects -### Supported languages +#### Programming languages - :heart: C# - :heart: Visual Basic - :yellow_heart: F# (some features disabled) - :thumbsdown: other languages -### Requirements - -#### Git repository - -Buildvana SDK assumes that your project is in a Git repository. Furthermore, if your project is packable (i.e. it is meant to be distributed as a NuGet package), Buildvana SDK assumes that it is hosted on a GitHub public repository, for the purposes of generating a symbol package with SourceLink support. +#### Git servers -Support for other Git servers will be implemented if / when asked for. It should be trivial enough, as long as there is a suitable SourceLink support package available. - -Private repositories may or may not work. Testers welcome. +- :heart: GitHub / GitHub Enterprise +- :yellow_heart: All others (no automatic SourceLink configuration) #### Toolchain -Building via the following tools is supported: - -- MSBuild v17.0 or newer (`msbuild`) running under .NET Framework 4.7.2 or newer / .NET 6.0 or newer -- Visual Studio 2022 v17.0 or newer (building from IDE) -- .NET SDK 6.0 or newer (`dotnet build`, `dotnet msbuild`, etc.) - -Latest versions of Visual Studio for Mac _should_ work, but we need confirmation by someone with a Mac. +- :heart: MSBuild v17.4 or newer (`msbuild`) running under .NET Framework 4.7.2 or newer / .NET 7.0 or newer +- :heart: Visual Studio 2022 v17.4 or newer (building from IDE) +- :heart: .NET SDK 7.0.100 or newer (`dotnet build`, `dotnet msbuild`, etc.) +- :question: Visual Studio for Mac (_should_ work, but we need confirmation by someone with a Mac) ## Quick start @@ -105,17 +88,39 @@ Latest versions of Visual Studio for Mac _should_ work, but we need confirmation ## Contributing -**TODO** +_Of course_ we accept contributions! :smiley: Just take a look at our [Code of Conduct](https://github.com/Tenacom/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributors guide](https://github.com/Tenacom/.github/blob/main/CONTRIBUTING.md), create your fork, and let's party! :tada: + +## Contributors + + + + + + + + + + + + + + + +

Riccardo De Agostini
+ + Add your contributions + +
+ + + + + ## Proudly built using Buildvana SDK -- [PolyKit](https://github.com/Buildvana/PolyKit), itself a part of the Buildvana project, is both a polyfill library _and_ a polyfill construction kit for C# projects. -- [L.o.U.I.S.](https://github.com/Tenacom/Louis) is a general-purpose library, providing both polyfills (via PolyKit) and commonly-used types, suitable for multi-platform libraries and applications. +- [PolyKit](https://github.com/Buildvana/PolyKit) is the last polyfill library you'll ever need for your C# projects. +- [L.o.U.I.S.](https://github.com/Tenacom/Louis) is a general-purpose library, providing commonly-used types, suitable for multi-platform libraries and applications. - [Cecil.XmlDocNames](https://github.com/Tenacom/Cecil.XmlDocNames) is a tiny library that generates XML-documentation-compliant names for [Mono.Cecil](https://github.com/jbevain/cecil) objects. - [ReSharper.ExportAnnotations](https://github.com/Tenacom/ReSharper.ExportAnnotations) lets you distribute ReSharper code annotations in XML format along with your libraries, without keeping a transient dependency on the [JetBrains.Annotations](https://www.nuget.org/packages/JetBrains.Annotations) package. -- Practically every one of [Tenacom](https://github.com/Tenacom)'s private projects since the first preview of Buildvana SDK: almost 50 libraries, a bunch of console apps, some WinForms apps, even an Avalonia app (with more coming soon). -## Credits - -The peaceful octopus logo is a modified version of [Peace](https://thenounproject.com/icon/1951204) by AomAm from [the Noun Project](https://thenounproject.com/). - -The font used in the logo is [Repo](https://fontlibrary.org/en/font/repo) by Stefan Peev, from [Font Library](https://fontlibrary.org). +- Practically every one of [Tenacom](https://github.com/Tenacom)'s private projects since the first preview of Buildvana SDK: almost 50 libraries, a bunch of console apps, some WinForms apps, even an Avalonia UI app (with more coming soon). diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index 396ecdb..3c498d0 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -1,15 +1,17 @@ -Buildvana SDK uses third-party material as listed below. -The attached notices are provided for informational purposes only. - -This project uses and/or incorporates third-party libraries or other resources +This project may use and/or incorporate third-party libraries or other resources that may be distributed under licenses different than this project. In the event that we accidentally failed to list a required notice, please bring it to our attention. Either post an issue, or email us: - info@buildvana.net + info@tenacom.it + +The attached notices are provided for information only. -The attached notices are provided for informational purposes only. +================================================================================================ +Third-party graphic elements (fonts, icons, etc.) +------------------------------------------------------------------------------------------------ +Third-party copyright notices for graphic elements are in graphics/README.md ================================================================================================ Nerdbank.MSBuildExtension - https://github.com/AArnott/Nerdbank.MSBuildExtension diff --git a/VERSION b/VERSION deleted file mode 100644 index 3cf286c..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.0.0-alpha.23 diff --git a/build-config.cmd b/build-config.cmd deleted file mode 100644 index 5dc844c..0000000 --- a/build-config.cmd +++ /dev/null @@ -1,8 +0,0 @@ -set _SOLUTION_NAME= -set _DEFAULT_TASK= -set _MSBUILD_CONFIGURATION=Release -set _MSBUILD_VERBOSITY=normal -set _MSBUILD_OPTIONS= -set _SKIP_INSPECT=0 -set _SKIP_TEST=0 -set _SKIP_PUSH=1 diff --git a/build.cake b/build.cake new file mode 100644 index 0000000..295ae27 --- /dev/null +++ b/build.cake @@ -0,0 +1,263 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#load "./build/BuildData.cake" +#load "./build/changelog.cake" +#load "./build/dotnet.cake" +#load "./build/environment.cake" +#load "./build/fail.cake" +#load "./build/filesystem.cake" +#load "./build/git.cake" +#load "./build/github.cake" +#load "./build/json.cake" +#load "./build/nbgv.cake" +#load "./build/options.cake" +#load "./build/process.cake" +#load "./build/public-api.cake" +#load "./build/setup-teardown.cake" +#load "./build/versioning.cake" +#load "./build/workspace.cake" + +#nullable enable + +using System; +using System.Text; + +using SysFile = System.IO.File; +using SysPath = System.IO.Path; + +// ============================================================================================= +// TASKS +// ============================================================================================= + +Task("Default") + .Description("Default task - Do nothing (but log build configuration data)") + .Does(context => { + context.Information("The default task does nothing. This is intentional."); + context.Information("Use `dotnet cake --description` to see the list of available tasks."); + }); + +Task("CleanAll") + .Description("Delete all output directories, VS data, R# caches") + .Does((context, data) => context.CleanAll(data)); + +Task("LocalCleanAll") + .Description("Like CleanAll, but only runs on a local machine") + .WithCriteria(data => !data.IsCI) + .Does((context, data) => context.CleanAll(data)); + +Task("Restore") + .Description("Restores dependencies") + .IsDependentOn("LocalCleanAll") + .Does((context, data) => context.RestoreSolution(data)); + +Task("Build") + .Description("Build all projects") + .IsDependentOn("Restore") + .Does((context, data) => context.BuildSolution(data, false)); + +Task("Test") + .Description("Build all projects and run tests") + .IsDependentOn("Build") + .Does((context, data) => context.TestSolution(data, false, false, true)); + +Task("Pack") + .Description("Build all projects, run tests, and prepare build artifacts") + .IsDependentOn("Test") + .Does((context, data) => context.PackSolution(data, false, false)); + +Task("Release") + .Description("Publish a new public release (CI only)") + .Does(async (context, data) => { + + // Perform some preliminary checks + Ensure(data.IsCI, "The Release target cannot run on a local system."); + Ensure(data.IsPublicRelease, "Cannot create a release from the current branch."); + + // Compute the version spec change to apply, if any + // This implies more checks and possibly throws, so do it as early as possible + var versionSpecChange = context.ComputeVersionSpecChange( + currentVersion: data.Version, + requestedChange: context.GetOption("versionSpecChange", VersionSpecChange.None), + checkPublicApi: context.GetOption("checkPublicApi", true)); + + // Identify Git user for later push if needed + context.GitSetUserIdentity("Buildvana", "buildvana@tenacom.it"); + + // Create the release as a draft first, so if the token has no permissions we can bail out early + var releaseId = await context.CreateDraftReleaseAsync(data); + var dupeTagChecked = false; + var committed = false; + try + { + // Modify version if required. + if (versionSpecChange != VersionSpecChange.None) + { + var versionFile = VersionFile.Load(); + if (versionFile.ApplyVersionSpecChange(context, versionSpecChange)) + { + versionFile.Save(); + UpdateRepo(versionFile.Path); + } + } + + // Update public API files only when releasing a stable version + if (!data.IsPrerelease) + { + var modified = context.TransferAllPublicApiToShipped().ToArray(); + if (modified.Length > 0) + { + context.Information($"{modified.Length} public API files were modified."); + UpdateRepo(modified); + } + else + { + context.Information("No public API files were modified."); + } + } + else + { + context.Information("Public API update skipped: not needed on prerelease."); + } + + // Update changelog only on non-prerelease + if (!data.IsPrerelease) + { + if (context.GetOption("checkChangelog", true)) + { + Ensure( + context.ChangelogHasUnreleasedChanges(data.ChangelogPath), + $"Changelog check failed: the \"Unreleased changes\" section is empty or only contains sub-section headings."); + + context.Information($"Changelog check successful: the \"Unreleased changes\" section is not empty."); + } + else + { + context.Information($"Changelog check skipped: option 'checkChangelog' is false."); + } + + // Update the changelog and commit the change before building. + // This ensures that the Git height is up to date when computing a version for the build artifacts. + context.PrepareChangelogForRelease(data); + UpdateRepo(data.ChangelogPath); + } + else + { + context.Information("Changelog update skipped: not needed on prerelease."); + } + + // Ensure that the release tag doesn't already exist. + // This assumes that full repo history has been checked out; + // however, that is already a prerequisite for using Nerdbank.GitVersioning. + Ensure(!context.GitTagExists(data.VersionStr), $"Tag {data.VersionStr} already exists in repository."); + dupeTagChecked = true; + + context.RestoreSolution(data); + context.BuildSolution(data, false); + context.TestSolution(data, false, false, false); + context.PackSolution(data, false, false); + + if (!data.IsPrerelease) + { + // Change the new section's title in the changelog to reflect the actual version. + context.UpdateChangelogNewSectionTitle(data); + UpdateRepo(data.ChangelogPath); + } + else + { + context.Information("Changelog section title update skipped: not needed on prerelease."); + } + + if (committed) + { + context.Information($"Git pushing changes to {data.Remote}..."); + _ = context.Exec("git", $"push {data.Remote} HEAD"); + } + else + { + context.Information("Git push skipped: no commit to push."); + } + + // Publish NuGet packages + context.NuGetPushAll(data); + + // If this is not a prerelease and we are releasing from the main branch, + // dispatch a separate workflow to publish documentation. + // Unless, of course, there is no documentation workflow. + FilePath pagesDeploymentWorkflow = ".github/workflows/deploy-pages.yml"; + if (!SysFile.Exists(pagesDeploymentWorkflow.FullPath)) + { + context.Information($"Documentation update skipped: there is no documentation workflow."); + } + else if (data.IsPrerelease) + { + context.Information("Documentation update skipped: not needed on prerelease."); + } + else if (data.Branch != "main") + { + context.Information($"Documentation update skipped: releasing from '{data.Branch}', not 'main'."); + } + else + { + await context.DispatchWorkflow(data, SysPath.GetFileName(pagesDeploymentWorkflow.FullPath), "main"); + } + + // Last but not least, publish the release. + await context.PublishReleaseAsync(data, releaseId); + + // Set outputs for subsequent steps in GitHub Actions + if (data.IsGitHubAction) + { + context.SetActionsStepOutput("version", data.VersionStr); + } + } + catch (Exception e) + { + context.Error(e is CakeException ? e.Message : $"{e.GetType().Name}: {e.Message}"); + await context.DeleteReleaseAsync(data, releaseId, dupeTagChecked ? data.VersionStr : null); + throw; + } + + void UpdateRepo(params FilePath[] files) + { + foreach (var path in files) + { + context.Verbose($"Git adding {path}..."); + _ = context.Exec( + "git", + new ProcessArgumentBuilder() + .Append("add") + .AppendQuoted(path.FullPath)); + } + + context.Information(committed ? "Amending commit..." : "Committing changed files..."); + var arguments = new ProcessArgumentBuilder().Append("commit"); + if (committed) + { + arguments = arguments.Append("--amend"); + } + + arguments = arguments.Append("-m").AppendQuoted("Prepare release [skip ci]"); + _ = context.Exec("git", arguments); + + // The commit changed the Git height, so update build data + // and amend the commit adding the right version. + // Amending a commit does not further change the Git height. + data.Update(context); + _ = context.Exec( + "git", + new ProcessArgumentBuilder() + .Append("commit") + .Append("--amend") + .Append("-m") + .AppendQuoted($"Prepare release {data.VersionStr} [skip ci]")); + + committed = true; + } + }); + +// ============================================================================================= +// EXECUTION +// ============================================================================================= + +RunTarget(Argument("target", "Default")); diff --git a/build.cmd b/build.cmd deleted file mode 100644 index 3169a57..0000000 --- a/build.cmd +++ /dev/null @@ -1,271 +0,0 @@ -@echo off & setlocal EnableExtensions EnableDelayedExpansion -pushd "%~dp0" - -:: .NET environment variables -set DOTNET_NOLOGO=true -set DOTNET_CLI_UI_LANGUAGE=en-US - -:: Configuration values -set _ARTIFACTS_DIRECTORY= -set _NUPKG_DIRECTORY= -set _SOLUTION_NAME= -set _DEFAULT_TASK= -set _MSBUILD_CONFIGURATION= -set _MSBUILD_VERBOSITY= -set _MSBUILD_OPTIONS= -set _SKIP_INSPECT= -set _SKIP_TEST= -set _SKIP_PUSH= -set _VS_MSBUILD_EXE= -set _NUGET_PUSH_SOURCE= -set _NUGET_PUSH_API_KEY= -set _NUGET_PUSH_SYMBOL_SOURCE= -set _NUGET_PUSH_SYMBOL_API_KEY= - -:: Load configuration -if exist build-config.cmd call build-config.cmd - -:: Default for solution name is the same name as containing folder, including extension -if "%_SOLUTION_NAME%" == "" call :F_SetToCurrentDirectoryName _SOLUTION_NAME -set _SOLUTION_FILE=%_SOLUTION_NAME%.sln -if not exist "%_SOLUTION_FILE%" ( - echo *** Solution file '%_SOLUTION_FILE%' not found. >CON: - exit /B 1 -) - -:: Other default configuration values -if "%_DEFAULT_TASK%"=="" set _DEFAULT_TASK=Pack -if "%_MSBUILD_CONFIGURATION%"=="" set _MSBUILD_CONFIGURATION=Release -if "%_MSBUILD_VERBOSITY%"=="" set _MSBUILD_VERBOSITY=normal -if "%_MSBUILD_OPTIONS%"=="" set _MSBUILD_OPTIONS= -if "%_SKIP_INSPECT%"=="" set _SKIP_INSPECT=0 -if "%_SKIP_TEST%"=="" set _SKIP_TEST=0 -if "%_SKIP_PUSH%"=="" set _SKIP_PUSH=0 -if "%_VS_MSBUILD_EXE%"=="" set _VS_MSBUILD_EXE="%ProgramFiles%\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" -if "%_NUGET_PUSH_SYMBOL_SOURCE%" == "" set _NUGET_PUSH_SYMBOL_SOURCE=%_NUGET_PUSH_SOURCE% -if "%_NUGET_PUSH_SYMBOL_API_KEY%" == "" set _NUGET_PUSH_SYMBOL_SOURCE=%_NUGET_PUSH_API_KEY% - -:: Default for artifacts directory, as per Buildvana SDK defaults -if "%_ARTIFACTS_DIRECTORY%" == "" set _ARTIFACTS_DIRECTORY=artifacts - -:: Default for NuGet package directory, as per Buildvana SDK defaults -if "%_NUPKG_DIRECTORY%" == "" set _NUPKG_DIRECTORY=%_ARTIFACTS_DIRECTORY%\%_MSBUILD_CONFIGURATION% - -:: Use Visual Studio's MSBuild if specified -if /I "%1" equ "VS" ( - set _VS=1 - shift -) - -:: Task to run -set _TASK=%1 -if "%_TASK%"=="" set _TASK=%_DEFAULT_TASK% - -:: Define task dependencies -if /I "%_TASK%"=="Clean" ( - call :F_Run_Tasks Clean -) else if /I "%_TASK%"=="Tools" ( - call :F_Run_Tasks Clean Tools -) else if /I "%_TASK%"=="Restore" ( - call :F_Run_Tasks Clean Tools Restore -) else if /I "%_TASK%"=="Build" ( - call :F_Run_Tasks Clean Tools Restore Build -) else if /I "%_TASK%"=="Inspect" ( - call :F_Run_Tasks Clean Tools Restore Build Inspect -) else if /I "%_TASK%"=="Test" ( - call :F_Run_Tasks Clean Tools Restore Build Inspect Test -) else if /I "%_TASK%"=="Pack" ( - call :F_Run_Tasks Clean Tools Restore Build Inspect Test Pack -) else if /I "%_TASK%"=="Push" ( - call :F_Run_Tasks Clean Tools Restore Build Inspect Test Pack Push -) else if /I "%_TASK%"=="All" ( - call :F_Run_Tasks Clean Tools Restore Build Inspect Test Pack Push -) else ( - echo *** Unknown task '%_TASK%' -) - -popd -exit /B %ERRORLEVEL% - -:: RUN TASKS - -:F_Run_Tasks -set _LOGS_DIR=logs -mkdir "%~dp0%_LOGS_DIR%" >nul 2>&1 -set _LOGFILE="%~dp0%_LOGS_DIR%\build.log" -if exist %_LOGFILE% del %_LOGFILE% -call :F_Timestamp - -:L_Run_Tasks_Loop -if "%1"=="" exit /B 0 -if "%_VS%" == "" ( call :T_%1 ) else ( call :T_VS_%1 ) -if errorlevel 1 exit /B %ERRORLEVEL% -shift -goto :L_Run_Tasks_Loop - -:: TASKS - -:T_Clean -:T_VS_Clean -call :F_Label Clean output directories -if exist "%~dp0%_ARTIFACTS_DIRECTORY%\" call :F_Exec rmdir /S /Q "%~dp0%_ARTIFACTS_DIRECTORY%" -if exist "%~dp0.vs\" call :F_Exec rmdir /S /Q "%~dp0.vs" -if exist "%~dp0_ReSharper.Caches\" call :F_Exec rmdir /S /Q "%~dp0_ReSharper.Caches" -for /F "tokens=*" %%G in ('dir /B /AD /S bin 2^>nul ^& dir /B /AD /S obj 2^>nul') do call :F_Exec rmdir /S /Q "%%G" -exit /B %ERRORLEVEL% - -:T_Tools -call :F_Label Restore .NET CLI tools -call :F_Exec dotnet tool restore -exit /B %ERRORLEVEL% - -:T_VS_Tools -:: No .NET CLI = no tools to restore -exit /B 0 - -:T_Inspect -if %_SKIP_INSPECT% gtr 0 ( - call :F_Label Skip code inspection ^(disabled in configuration^) - exit /B 0 -) -call :F_Label Inspect code with ReSharper tools -call :F_Exec dotnet jb inspectcode "%_SOLUTION_FILE%" --no-build --output=%_LOGS_DIR%\inspect.log --format=Text -exit /B %ERRORLEVEL% - -:T_VS_Inspect -call :F_Label Skip code inspection ^(always disabled when using Visual Studio's MSBuild^) -exit /B 0 - -:T_Restore -call :F_Label Restore dependencies -call :F_Exec dotnet restore --verbosity %_MSBUILD_VERBOSITY% %_MSBUILD_OPTIONS% -exit /B %ERRORLEVEL% - -:T_VS_Restore -call :F_Label Restore dependencies -call :F_Exec %_VS_MSBUILD_EXE% -t:restore -v:%_MSBUILD_VERBOSITY% %_MSBUILD_OPTIONS% -exit /B %ERRORLEVEL% - -:T_Build -call :F_Label Build solution -call :F_Exec dotnet build -c %_MSBUILD_CONFIGURATION% --verbosity %_MSBUILD_VERBOSITY% --no-restore -maxCpuCount:1 %_MSBUILD_OPTIONS% -exit /B %ERRORLEVEL% - -:T_VS_Build -call :F_Label Build solution -call :F_Exec %_VS_MSBUILD_EXE% -t:build -p:Configuration=%_MSBUILD_CONFIGURATION% -v:%_MSBUILD_VERBOSITY% -restore:False -maxCpuCount:1 %_MSBUILD_OPTIONS% -exit /B %ERRORLEVEL% - -:T_Test -if %_SKIP_TEST% gtr 0 ( - call :F_Label Skip unit tests ^(disabled in configuration^) - exit /B 0 -) -call :F_Label Run unit tests -call :F_Exec dotnet test -c %_MSBUILD_CONFIGURATION% --verbosity %_MSBUILD_VERBOSITY% --no-build %_MSBUILD_OPTIONS% -exit /B %ERRORLEVEL% - -:T_VS_Test -call :F_Label Skipping unit tests ^(always disabled when using Visual Studio's MSBuild^) -exit /B 0 - -:T_Pack -call :F_Label Prepare for distribution -call :F_Exec dotnet pack -c %_MSBUILD_CONFIGURATION% --verbosity %_MSBUILD_VERBOSITY% --no-build -maxCpuCount:1 %_MSBUILD_OPTIONS% -exit /B %ERRORLEVEL% - -:T_VS_Pack -call :F_Label Prepare for distribution -call :F_Exec %_VS_MSBUILD_EXE% -t:pack -p:Configuration=%_MSBUILD_CONFIGURATION% -v:%_MSBUILD_VERBOSITY% -p:NoBuild=true -maxCpuCount:1 %_MSBUILD_OPTIONS% -exit /B %ERRORLEVEL% - -:T_Push -if %_SKIP_PUSH% gtr 0 ( - call :F_Label Skipping NuGet push ^(disabled in configuration^) - exit /B 0 -) -if not exist "%~dp0%_NUPKG_DIRECTORY%\*.nupkg" ( - call :F_Label Skipping NuGet push ^(no packages to push^) - exit /B 0 -) -if "%_NUGET_PUSH_SOURCE%" == "" ( - call :F_Error _NUGET_PUSH_SOURCE not configured! - exit /B 1 -) -if "%_NUGET_PUSH_API_KEY%" == "" ( - call :F_Error _NUGET_PUSH_API_KEY not configured! - exit /B 1 -) -call :F_Label Push packages to NuGet server -call :F_Exec pushd "%~dp0%_NUPKG_DIRECTORY%" -for /F "tokens=*" %%G in ('dir /B *.nupkg') do ( - call :F_Exec dotnet nuget push %%G --source %_NUGET_PUSH_SOURCE% --api-key %_NUGET_PUSH_API_KEY% --symbol-source %_NUGET_PUSH_SYMBOL_SOURCE% --symbol-api-key %_NUGET_PUSH_SYMBOL_API_KEY% - if errorlevel 1 exit /B %ERRORLEVEL% -) -call :F_Exec popd -exit /B 0 - -:T_VS_Push -call :F_Label Skip NuGet push ^(always disabled when using Visual Studio's MSBuild^) -exit /B 0 - -:: SUB-ROUTINES - -:F_CleanDirectory -echo --- rmdir /S /Q %1 >CON: -echo --- rmdir /S /Q %1 >>%_LOGFILE% 2>&1 -rmdir /S /Q %1 >nul 2>&1 -exit /B 0 - -:F_Exec -echo --- %* >CON: -echo --- %* >>%_LOGFILE% 2>&1 -%* >>%_LOGFILE% 2>&1 -set _EL=%ERRORLEVEL% -echo; >>%_LOGFILE% 2>&1 -call :F_Display_Errorlevel %_EL% -exit /B %_EL% - -:F_Timestamp -call :F_Timestamp_Core >CON: -call :F_Timestamp_Core >>%_LOGFILE% 2>&1 -exit /B 0 - -:F_Timestamp_Core -echo; -echo ===^>^>^> '%_SOLUTION_NAME%' %DATE% %TIME% -exit /B 0 - -:F_Label -call :F_Label_Core %* >CON: -call :F_Label_Core %* >>%_LOGFILE% 2>&1 -exit /B 0 - -:F_Label_Core -echo; -echo ^>^>^> %* -exit /B 0 - -:F_Error -call :F_Error_Core %* >CON: -call :F_Error_Core %* >>%_LOGFILE% 2>&1 -exit /B 0 - -:F_Error_Core -echo ^*^*^* %* -exit /B 0 - -:F_Display_Errorlevel -if "%1%"=="0" exit /B 0 -call :F_Error ERRORLEVEL = %1 -exit /B 0 - -:F_SetToCurrentDirectoryName -call :F_SetToCurrentDirectoryName_Core %1 "%CD%" -exit /B 0 - -:F_SetToCurrentDirectoryName_Core -set %1=%~nx2 -exit /B 0 - -:: EOF diff --git a/build/BuildData.cake b/build/BuildData.cake new file mode 100644 index 0000000..14bc2fc --- /dev/null +++ b/build/BuildData.cake @@ -0,0 +1,195 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +using NuGet.Versioning; + +// --------------------------------------------------------------------------------------------- +// BuildData: a record to hold build configuration data +// --------------------------------------------------------------------------------------------- + +/* + * Summary : Holds configuration data for the build. + */ +sealed class BuildData +{ + /* + * Summary : Initializes a new instance of the BuildData class. + * Params : host - The Cake build script host. + */ + public BuildData(ICakeContext context) + { + Ensure(context.TryGetRepositoryInfo(out var repository), 255, "Cannot determine repository owner and name."); + var changelogPath = new FilePath("CHANGELOG.md"); + var solutionPath = context.GetFiles("*.sln").FirstOrDefault() ?? Fail(255, "Cannot find a solution file."); + var solution = context.ParseSolution(solutionPath); + var configuration = context.Argument("configuration", "Release"); + var artifactsPath = new DirectoryPath("artifacts").Combine(configuration); + var testResultsPath = new DirectoryPath("TestResults"); + var isGitHubAction = context.EnvironmentVariable("GITHUB_ACTIONS", false); + var isCI = isGitHubAction + || context.EnvironmentVariable("CI", false) + || context.EnvironmentVariable("CONTINUOUS_INTEGRATION", false) + || context.EnvironmentVariable("TF_BUILD", false) + || context.EnvironmentVariable("GITLAB_CI", false) + || context.EnvironmentVariable("TRAVIS", false) + || context.EnvironmentVariable("APPVEYOR", false) + || context.EnvironmentVariable("CIRCLECI", false) + || context.HasEnvironmentVariable("TEAMCITY_VERSION") + || context.HasEnvironmentVariable("JENKINS_URL"); + + var (versionStr, @ref, isPublicRelease, isPrerelease) = context.GetVersionInformation(); + var version = SemanticVersion.Parse(versionStr); + var branch = context.GetCurrentGitBranch(); + var msBuildSettings = new DotNetMSBuildSettings { + MaxCpuCount = 1, + ContinuousIntegrationBuild = isCI, + NoLogo = true, + }; + + RepositoryHostUrl = repository.HostUrl; + RepositoryOwner = repository.Owner; + RepositoryName = repository.Name; + Remote = repository.Remote; + Ref = @ref; + Branch = branch; + ArtifactsPath = artifactsPath; + TestResultsPath = testResultsPath; + ChangelogPath = changelogPath; + SolutionPath = solutionPath; + Solution = solution; + Configuration = configuration; + VersionStr = versionStr; + Version = version; + IsPublicRelease = isPublicRelease; + IsPrerelease = isPrerelease; + IsGitHubAction = isGitHubAction; + IsCI = isCI; + MSBuildSettings = msBuildSettings; + + context.Information("Build configuration data:"); + context.Information($"Repository : {RepositoryHostUrl}/{RepositoryOwner}/{RepositoryName}"); + context.Information($"Git remote name : {Remote}"); + context.Information($"Git reference : {Ref}"); + context.Information($"Branch : {Branch}"); + context.Information($"Build environment : {(IsCI ? "cloud" : "local")}"); + context.Information($"Solution : {SolutionPath.GetFilename()}"); + context.Information($"Version : {Version}"); + context.Information($"Public release : {(IsPublicRelease ? "yes" : "no")}"); + context.Information($"Prerelease : {(IsPrerelease ? "yes" : "no")}"); + } + + /* + * Summary : Gets the repository host URL (e.g. "https://github.com" for a repository hosted on GitHub.) + */ + public string RepositoryHostUrl { get; } + + /* + * Summary : Gets the repository owner (e.g. "Tenacom" for repository Tenacom/SomeLibrary.) + */ + public string RepositoryOwner { get; } + + /* + * Summary : Gets the repository owner (e.g. "SomeLibrary" for repository Tenacom/SomeLibrary.) + */ + public string RepositoryName { get; } + + /* + * Summary : Gets the name of the Git remote that points to the main repository + * (usually "origin" in cloud builds, "upstream" when working locally on a fork.) + */ + public string Remote { get; } + + /* + * Summary : Gets Git's HEAD reference or SHA. + */ + public string Ref { get; private set; } + + /* + * Summary : Gets Git's HEAD branch name, or the empty string if not on a branch. + */ + public string Branch { get; } + + /* + * Summary : Gets the path of the directory where build artifacts are stored. + */ + public DirectoryPath ArtifactsPath { get; } + + /* + * Summary : Gets the path of the directory where test results and coverage reports are stored. + */ + public DirectoryPath TestResultsPath { get; } + + /* + * Summary : Gets the path of the CHANGELOG.md file. + */ + public FilePath ChangelogPath { get; } + + /* + * Summary : Gets the path of the solution file. + */ + public FilePath SolutionPath { get; } + + /* + * Summary : Gets the parsed solution. + */ + public SolutionParserResult Solution { get; } + + /* + * Summary : Gets the configuration to build. + */ + public string Configuration { get; } + + /* + * Summary : Gets the version to build, as a string computed by Nerdbank.GitVersioning. + */ + public string VersionStr { get; private set; } + + /* + * Summary : Gets the version to build, as a SemanticVersion object. + */ + public SemanticVersion Version { get; private set; } + + /* + * Summary : Gets a value that indicates whether a public release can be built. + * Value : True if Git's HEAD is on a public release branch, as indicated in version.json; + * otherwise, false. + */ + public bool IsPublicRelease { get; private set; } + + /* + * Summary : Gets a value that indicates whether the version to build is a prerelease. + */ + public bool IsPrerelease { get; private set; } + + /* + * Summary : Gets a value that indicates whether Cake is running in a GitHub Actions workflow. + */ + public bool IsGitHubAction { get; } + + /* + * Summary : Gets a value that indicates whether Cake is running on a cloud build server. + */ + public bool IsCI { get; } + + /* + * Summary : Gets the MSBuild settings to use for DotNet aliases. + */ + public DotNetMSBuildSettings MSBuildSettings { get; } + + /* + * Summary : Update build configuration data, typically after a commit. + * Params : context - The Cake context. + */ + public void Update(ICakeContext context) + { + (VersionStr, Ref, IsPublicRelease, IsPrerelease) = context.GetVersionInformation(); + Version = SemanticVersion.Parse(VersionStr); + context.Information("Updated build configuration data:"); + context.Information($"Git reference : {Ref}"); + context.Information($"Version : {Version}"); + context.Information($"Public release : {(IsPublicRelease ? "yes" : "no")}"); + context.Information($"Prerelease : {(IsPrerelease ? "yes" : "no")}"); + } +} diff --git a/build/DocFx.cake b/build/DocFx.cake new file mode 100644 index 0000000..5826ebd --- /dev/null +++ b/build/DocFx.cake @@ -0,0 +1,124 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#tool nuget:?package=docfx.console&version=2.59.4 + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// DocFx class +// --------------------------------------------------------------------------------------------- + +using System; +using System.Runtime.InteropServices; + +/* + * Summary : Implements DocFx operations + */ +sealed class DocFx +{ + private const string ToolExeName = "docfx.exe"; + + private readonly DirectoryPath _docsPath; + private FilePath? _docFxPath; + + /* + * Summary : Initializes a new instance of the DocFx class. + * Params : context - The Cake context. + * docsPath - The path to the folder where DocFX operates. + */ + public DocFx(ICakeContext context, BuildData buildData, DirectoryPath docsPath) + { + Context = context; + BuildData = buildData; + _docsPath = docsPath; + } + + private ICakeContext Context { get; } + + private BuildData BuildData { get; } + + /* + * Summary : Extracts language metadata according to docfx.json settings. + */ + public void Metadata() + { + var docFxJsonPath = _docsPath.CombineWithFilePath("docfx.json"); + var json = LoadJsonObject(docFxJsonPath); + if (!json.TryGetPropertyValue("metadata", out _)) + { + Context.Information("No metadata to generate."); + return; + } + + Context.Information("Running DocFx..."); + Run("metadata"); + } + + /* + * Summary : Generates documentation according to docfx.json settings. + */ + public void Build() + { + Context.Information("Running DocFx..."); + Run("build"); + } + + /* + * Summary : Hosts the built documentation web site. + */ + public void Serve() + { + if (BuildData.IsCI) + { + Context.Information("DocFX web server not suitable for cloud builds, skipping."); + return; + } + + Context.Information("Starting DocFX web server..."); + var (_, process) = Start("serve _site"); + Console.WriteLine("Press any key to stop serving..."); + _ = WaitForKey(); + Context.Information("Stopping DocFX web server..."); + process.Kill(); + process.WaitForExit(); + } + + private static ConsoleKeyInfo WaitForKey() + { + while (Console.KeyAvailable) + { + _ = Console.ReadKey(true); + } + + return Console.ReadKey(true); + } + + private void Run(ProcessArgumentBuilder arguments) + { + var (commandName, process) = Start(arguments); + process.WaitForExit(); + var exitCode = process.GetExitCode(); + Ensure(exitCode == 0, $"{commandName} exited with code {exitCode}."); + } + + private (string commandName, IProcess Process) Start(ProcessArgumentBuilder arguments) + { + _docFxPath ??= Context.Tools.Resolve(ToolExeName); + Ensure(_docFxPath != null, $"Cannot find {ToolExeName}"); + FilePath command = _docFxPath; + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + command = "mono"; + arguments = arguments.PrependQuoted(_docFxPath.FullPath); + } + + var process = Context.StartAndReturnProcess(command, new ProcessSettings() + { + Arguments = arguments, + WorkingDirectory = _docsPath, + }); + + return (command.GetFilenameWithoutExtension().ToString(), process); + } +} diff --git a/build/THIRD-PARTY-NOTICES b/build/THIRD-PARTY-NOTICES new file mode 100644 index 0000000..4b11eb5 --- /dev/null +++ b/build/THIRD-PARTY-NOTICES @@ -0,0 +1,34 @@ +These scripts may use and/or incorporate third-party libraries or other resources +that may be distributed under licenses different than this project. + +In the event that we accidentally failed to list a required notice, please +bring it to our attention. Either post an issue, or email us: + + info@tenacom.it + +The attached notices are provided for information only. + +================================================================================================ +Humanizer - https://github.com/Humanizr/Humanizer +------------------------------------------------------------------------------------------------ +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/build/changelog.cake b/build/changelog.cake new file mode 100644 index 0000000..a361023 --- /dev/null +++ b/build/changelog.cake @@ -0,0 +1,266 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Changelog management helpers +// --------------------------------------------------------------------------------------------- + +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +using SysFile = System.IO.File; + +/* + * Summary : Checks the changelog for contents in the "Unreleased changes" section. + * Params : context - The Cake context. + * changelogPath - The FilePath of the changelog. + * Returns : If there are any contents (excluding blank lines and sub-section headings) + * in the "Unreleased changes" section, true; otherwise, false. + */ +static bool ChangelogHasUnreleasedChanges(this ICakeContext context, FilePath changelogPath) +{ + using (var reader = new StreamReader(changelogPath.FullPath, Encoding.UTF8)) + { + var sectionHeadingRegex = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + var subSectionHeadingRegex = new Regex(@"^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + string? line; + do + { + line = reader.ReadLine(); + } while (line != null && !sectionHeadingRegex.IsMatch(line)); + + Ensure(line != null, $"{changelogPath.GetFilename()} contains no sections."); + for (; ;) + { + line = reader.ReadLine(); + if (line == null || sectionHeadingRegex.IsMatch(line)) + { + break; + } + + if (!string.IsNullOrWhiteSpace(line) && !subSectionHeadingRegex.IsMatch(line)) + { + return true; + } + } + } + + return false; +} + +/* + * Summary : Prepares the changelog for a release by moving the contents of the "Unreleased changes" section + * to a new section. + * Params : context - The Cake context. + * data - Build configuration data. + */ +static void PrepareChangelogForRelease(this ICakeContext context, BuildData data) +{ + context.Information("Updating changelog..."); + var encoding = new UTF8Encoding(false, true); + var sb = new StringBuilder(); + using (var reader = new StreamReader(data.ChangelogPath.FullPath, encoding)) + using (var writer = new StringWriter(sb, CultureInfo.InvariantCulture)) + { + // Using a StringWriter instead of a StringBuilder allows for a custom line separator + // Under Windows, a StringBuilder would only use "\r\n" as a line separator, which would be wrong in this case + writer.NewLine = "\n"; + var sectionHeadingRegex = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + var subSectionHeadingRegex = new Regex(@"^ {0,3}###($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + var subSections = new List<(string Header, List Lines)>(); + subSections.Add(("", new List())); + var subSectionIndex = 0; + + const int ReadingFileHeader = 0; + const int ReadingUnreleasedChangesSection = 1; + const int ReadingRemainderOfFile = 2; + const int ReadingDone = 3; + var state = ReadingFileHeader; + while (state != ReadingDone) + { + var line = reader.ReadLine(); + switch (state) + { + case ReadingFileHeader: + Ensure(line != null, $"{data.ChangelogPath.GetFilename()} contains no sections."); + + // Copy everything up to an including the first section heading (which we assume is "Unreleased changes") + writer.WriteLine(line); + if (sectionHeadingRegex.IsMatch(line)) + { + state = ReadingUnreleasedChangesSection; + } + + break; + case ReadingUnreleasedChangesSection: + if (line == null) + { + // The changelog only contains the "Unreleased changes" section; + // this happens when no release has been published yet + WriteNewSections(true); + state = ReadingDone; + break; + } + + if (sectionHeadingRegex.IsMatch(line)) + { + // Reached header of next section + WriteNewSections(false); + writer.WriteLine(line); + state = ReadingRemainderOfFile; + break; + } + + if (subSectionHeadingRegex.IsMatch(line)) + { + subSections.Add((line, new List())); + ++subSectionIndex; + break; + } + + subSections[subSectionIndex].Lines.Add(line); + break; + case ReadingRemainderOfFile: + if (line == null) + { + state = ReadingDone; + break; + } + + writer.WriteLine(line); + break; + default: + Fail($"Internal error: reading state corrupted ({state})."); + throw null; + } + } + + void WriteNewSections(bool atEndOfFile) + { + // Create empty sub-sections in new "Unreleased changes" section + foreach (var subSection in subSections.Skip(1)) + { + writer.WriteLine(string.Empty); + writer.WriteLine(subSection.Header); + } + + // Write header of new release section + writer.WriteLine(string.Empty); + writer.WriteLine("## " + MakeChangelogSectionTitle(data)); + + var newSectionLines = CollectNewSectionLines(); + var newSectionCount = newSectionLines.Count; + if (atEndOfFile) + { + // If there is no other section after the new release, + // we don't want extra blank lines at EOF + while (newSectionCount > 0 && string.IsNullOrEmpty(newSectionLines[newSectionCount - 1])) + { + --newSectionCount; + } + } + + foreach (var newSectionLine in newSectionLines.Take(newSectionCount)) + { + writer.WriteLine(newSectionLine); + } + } + + List CollectNewSectionLines() + { + var result = new List(subSections[0].Lines); + + // Copy only sub-sections that have actual content + foreach (var subSection in subSections.Skip(1).Where(s => s.Lines.Any(l => !string.IsNullOrWhiteSpace(l)))) + { + result.Add(subSection.Header); + foreach (var contentLine in subSection.Lines) + { + result.Add(contentLine); + } + } + + return result; + } + } + + SysFile.WriteAllText(data.ChangelogPath.FullPath, sb.ToString(), encoding); +} + +/* + * Summary : Updates the heading of the first section of the changelog after the "Unreleased changes" section + * to reflect a change in the released version. + * Params : context - The Cake context. + * data - Build configuratiohn data. + */ +static void UpdateChangelogNewSectionTitle(this ICakeContext context, BuildData data) +{ + context.Information("Updating changelog's new release section title..."); + var encoding = new UTF8Encoding(false, true); + var sb = new StringBuilder(); + using (var reader = new StreamReader(data.ChangelogPath.FullPath, encoding)) + using (var writer = new StringWriter(sb, CultureInfo.InvariantCulture)) + { + // Using a StringWriter instead of a StringBuilder allows for a custom line separator + // Under Windows, a StringBuilder would only use "\r\n" as a line separator, which would be wrong in this case + writer.NewLine = "\n"; + var sectionHeadingRegex = new Regex(@"^ {0,3}##($|[^#])", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + const int ReadingFileHeader = 0; + const int ReadingUnreleasedChangesSection = 1; + const int ReadingRemainderOfFile = 2; + const int ReadingDone = 3; + var state = ReadingFileHeader; + while (state != ReadingDone) + { + var line = reader.ReadLine(); + switch (state) + { + case ReadingFileHeader: + Ensure(line != null, $"{data.ChangelogPath.GetFilename()} contains no sections."); + writer.WriteLine(line); + if (sectionHeadingRegex.IsMatch(line)) + { + state = ReadingUnreleasedChangesSection; + } + + break; + case ReadingUnreleasedChangesSection: + Ensure(line != null, $"{data.ChangelogPath.GetFilename()} contains only one section."); + if (sectionHeadingRegex.IsMatch(line)) + { + // Replace header of second section + writer.WriteLine("## " + MakeChangelogSectionTitle(data)); + state = ReadingRemainderOfFile; + break; + } + + writer.WriteLine(line); + break; + case ReadingRemainderOfFile: + if (line == null) + { + state = ReadingDone; + break; + } + + writer.WriteLine(line); + break; + default: + Fail($"Internal error: reading state corrupted ({state})."); + throw null; + } + } + } + + SysFile.WriteAllText(data.ChangelogPath.FullPath, sb.ToString(), encoding); +} + +static string MakeChangelogSectionTitle(BuildData data) +{ + return $"[{data.VersionStr}](https://github.com/{data.RepositoryOwner}/{data.RepositoryName}/releases/tag/{data.VersionStr}) ({DateTime.Now:yyyy-MM-dd})"; +} diff --git a/build/dotnet.cake b/build/dotnet.cake new file mode 100644 index 0000000..f6824c2 --- /dev/null +++ b/build/dotnet.cake @@ -0,0 +1,143 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// .NET SDK helpers +// --------------------------------------------------------------------------------------------- + +using System.IO; +using System.Linq; + +using SysDirectory = System.IO.Directory; +using SysPath = System.IO.Path; + +/* + * Summary : Restore all NuGet packages for the solution. + * Params : context - The Cake context. + * data - Build configuration data. + */ +static void RestoreSolution(this ICakeContext context, BuildData data) +{ + context.Information("Restoring NuGet packages for solution..."); + context.DotNetRestore(data.SolutionPath.FullPath, new() { + DisableParallel = true, + Interactive = false, + MSBuildSettings = data.MSBuildSettings, + }); +} + +/* + * Summary : Build all projects in teh solution. + * Params : context - The Cake context. + * data - Build configuration data. + * restore - true to restore NuGet packages before building, false otherwise. + */ +static void BuildSolution(this ICakeContext context, BuildData data, bool restore) +{ + context.Information($"Building solution (restore = {restore})..."); + context.DotNetBuild(data.SolutionPath.FullPath, new() { + Configuration = data.Configuration, + MSBuildSettings = data.MSBuildSettings, + NoLogo = true, + NoRestore = !restore, + }); +} + +/* + * Summary : Run all unit tests for the solution. + * Params : context - The Cake context. + * data - Build configuration data. + * restore - true to restore NuGet packages before testing, false otherwise. + * build - true to build the solution before testing, false otherwise. + * collect - true to collect coverage data with Coverlet + * Remarks : If successful, this method will merge all coverage reports generated by VSTest + * into a single file suitable for upload to Codecov. + */ +static void TestSolution(this ICakeContext context, BuildData data, bool restore, bool build, bool collect) +{ + context.Information($"Running tests (restore = {restore}, build = {build}, collect = {collect})..."); + context.DotNetTest(data.SolutionPath.FullPath, new() { + Configuration = data.Configuration, + NoBuild = !build, + NoLogo = true, + NoRestore = !restore, + ArgumentCustomization = args => collect + ? args.Append("--collect:\"XPlat Code Coverage\"") + : args, + }); + + // Merge coverage reports only if there are any + if (collect) + { + if (!context.FileSystem.Exist(data.TestResultsPath) || !context.GetSubDirectories(data.TestResultsPath).Any()) + { + context.Information("No coverage reports were generated."); + } + else + { + context.Information("Merging coverage reports..."); + const string CoverageDataFileName = "coverage.cobertura.xml"; + var coverageDataGlob = SysPath.Combine(data.TestResultsPath.FullPath, "*", CoverageDataFileName); + context.DotNetTool($"reportgenerator \"-reports:{coverageDataGlob}\" \"-targetDir:{data.TestResultsPath.FullPath}\" -reporttypes:Cobertura"); + } + } +} + +/* + * Summary : Run the Pack target on the solution. This usually produces NuGet packages, + * but Buildvana SDK may hijack the target to produce, for example, setup executables. + * Params : context - The Cake context. + * data - Build configuration data. + * restore - true to restore NuGet packages before packing, false otherwise. + * build - true to build the solution before packing, false otherwise. + */ +static void PackSolution(this ICakeContext context, BuildData data, bool restore, bool build) +{ + context.Information($"Packing solution (restore = {restore}, build = {build})..."); + context.DotNetPack(data.SolutionPath.FullPath, new() { + Configuration = data.Configuration, + MSBuildSettings = data.MSBuildSettings, + NoBuild = !build, + NoLogo = true, + NoRestore = !restore, + }); +} + +/* + * Summary : Push all produced NuGet packages to the appropriate NuGet server. + * Params : context - The Cake context. + * data - Build configuration data. + * Remarks : - This method uses the following environment variables: + * * PRERELEASE_NUGET_SOURCE - NuGet source URL where to push prerelease packages + * * RELEASE_NUGET_SOURCE - NuGet source URL where to push non-prerelease packages + * * PRERELEASE_NUGET_KEY - API key for PRERELEASE_NUGET_SOURCE + * * RELEASE_NUGET_KEY - API key for RELEASE_NUGET_SOURCE + * - If there are no .nupkg files in the designated artifacts directory, this method does nothing. + */ +static void NuGetPushAll(this ICakeContext context, BuildData data) +{ + const string nupkgMask = "*.nupkg"; + if (!SysDirectory.EnumerateFiles(data.ArtifactsPath.FullPath, nupkgMask).Any()) + { + context.Verbose("No .nupkg files to push."); + return; + } + + var nugetSource = context.GetOptionOrFail(data.IsPrerelease ? "prereleaseNugetSource" : "releaseNugetSource"); + var nugetApiKey = context.GetOptionOrFail(data.IsPrerelease ? "prereleaseNugetKey" : "releaseNugetKey"); + var nugetPushSettings = new DotNetNuGetPushSettings { + ForceEnglishOutput = true, + Source = nugetSource, + ApiKey = nugetApiKey, + SkipDuplicate = true, + }; + + var packages = SysPath.Combine(data.ArtifactsPath.FullPath, nupkgMask); + foreach (var path in context.GetFiles(packages)) + { + context.Information($"Pushing {path} to {nugetSource}..."); + context.DotNetNuGetPush(path, nugetPushSettings); + } +} diff --git a/build/environment.cake b/build/environment.cake new file mode 100644 index 0000000..0419bec --- /dev/null +++ b/build/environment.cake @@ -0,0 +1,28 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Environment helpers +// --------------------------------------------------------------------------------------------- + +/* + * Summary : Gets a string from the environment, failing if the value is not found or is the empty string. + * Params : name - The name of the environment variable to read. + * fallbackName - The name of another environment variable to read if name is not found or its value is the empty string. + * Returns : The value of an environment variable. + */ +string GetEnvironmentString(string name, string fallbackName = "") +{ + var result = EnvironmentVariable(name, string.Empty); + if (!string.IsNullOrEmpty(result)) + { + return result; + } + + Ensure(!string.IsNullOrEmpty(fallbackName), $"Environment variable {name} is missing or has an empty value."); + result = EnvironmentVariable(fallbackName, string.Empty); + Ensure(!string.IsNullOrEmpty(result), 255, $"Both environment variables {name} and {fallbackName} are missing or have an empty value."); + return result; +} diff --git a/build/fail.cake b/build/fail.cake new file mode 100644 index 0000000..0a8745d --- /dev/null +++ b/build/fail.cake @@ -0,0 +1,75 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Build failure helpers +// --------------------------------------------------------------------------------------------- + +using System.Diagnostics.CodeAnalysis; + +/* + * Summary : Fails the build with the specified message. + * This method does not return. + * Params : message - A message explaining the reason for failing the build. + */ +[DoesNotReturn] +static void Fail(string message) => throw new CakeException(message); + +/* + * Summary : Fails the build with the specified message. + * This method does not return. + * Type : T - The expected return type. + * Params : message - A message explaining the reason for failing the build. + * Returns : This method never returns. + */ +[DoesNotReturn] +static T Fail(string message) => throw new CakeException(message); + +/* + * Summary : Fails the build with the specified exit code and message. + * This method does not return. + * Params : exitCode - The Cake exit code. + * message - A message explaining the reason for failing the build. + */ +[DoesNotReturn] +static void Fail(int exitCode, string message) => throw new CakeException(exitCode, message); + +/* + * Summary : Fails the build with the specified exit code and message. + * This method does not return. + * Type : T - The expected return type. + * Params : exitCode - The Cake exit code. + * message - A message explaining the reason for failing the build. + * Returns : This method never returns. + */ +[DoesNotReturn] +static T Fail(int exitCode, string message) => throw new CakeException(exitCode, message); + +/* + * Summary : Fails the build with the specified message if a condition is not verified. + * Params : condition - The condition to verify. + * message - A message explaining the reason for failing the build. + */ +static void Ensure([DoesNotReturnIf(false)] bool condition, string message) +{ + if (!condition) + { + throw new CakeException(message); + } +} + +/* + * Summary : Fails the build with the specified message if a condition is not verified. + * Params : condition - The condition to verify. + * exitCode - The Cake exit code. + * message - A message explaining the reason for failing the build. + */ +static void Ensure([DoesNotReturnIf(false)] bool condition, int exitCode, string message) +{ + if (!condition) + { + throw new CakeException(exitCode, message); + } +} diff --git a/build/filesystem.cake b/build/filesystem.cake new file mode 100644 index 0000000..1a541f9 --- /dev/null +++ b/build/filesystem.cake @@ -0,0 +1,25 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// File system helpers +// --------------------------------------------------------------------------------------------- + +/* + * Summary : Delete a directory, including its contents, if it exists. + * Params : context - The Cake context. + * directory - The directory to delete. + */ +static void DeleteDirectoryIfExists(this ICakeContext context, DirectoryPath directory) +{ + if (!context.DirectoryExists(directory)) + { + context.Verbose($"Skipping non-existent directory: {directory}"); + return; + } + + context.Information($"Deleting directory: {directory}"); + context.DeleteDirectory(directory, new() { Force = false, Recursive = true }); +} diff --git a/build/git.cake b/build/git.cake new file mode 100644 index 0000000..ede3aba --- /dev/null +++ b/build/git.cake @@ -0,0 +1,180 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Git repository helpers +// --------------------------------------------------------------------------------------------- + +using System; +using System.Linq; + +/* + * Summary : Gets the name of the current Git branch. + * Params : context - The Cake context. + * Returns : If HEAD is on a branch, the name of the branch; otherwise, the empty string. + */ +static string GetCurrentGitBranch(this ICakeContext context) => context.Exec("git", "branch --show-current").FirstOrDefault(string.Empty); + +/* + * Summary : Attempts to get information about the remote repository. + * Params : context - The Cake context. + * Returns : Remote - The Git remote name. + * HostUrl - The base URL of the Git repository host. + * Owner - The repository owner. + * Name - The repository name. + * Remarks : - If the githubRepository argument is given, or the GITHUB_REPOSITORY environment variable is set + * (as it happens in GitHub Actions,) Owner and Name are taken from there, while Remote is set + * to the first Git remote found whose fetch URL matches them. + * - If GITHUB_REPOSITORY is not available, Git remote fetch URLs are parsed for Owner and Name; + * remotes "upstream" and "origin" are tested, in that order, in case "origin" is a fork. + */ +static bool TryGetRepositoryInfo(this ICakeContext context, out (string Remote, string HostUrl, string Owner, string Name) result) +{ + return TryGetRepositoryInfoFromGitHubActions(out result) + || TryGetRepositoryInfoFromGitRemote("upstream", out result) + || TryGetRepositoryInfoFromGitRemote("origin", out result); + + bool TryGetRepositoryInfoFromGitHubActions(out (string Remote, string HostUrl, string Owner, string Name) result) + { + var repository = context.GetOption("githubRepository", string.Empty); + if (string.IsNullOrEmpty(repository)) + { + result = default; + return false; + } + + var hostUrl = context.GetOptionOrFail("githubServerUrl"); + var segments = repository.Split('/'); + foreach (var remote in context.Exec("git", "remote")) + { + if (TryGetRepositoryInfoFromGitRemote(remote, out result) + && string.Equals(result.HostUrl, hostUrl, StringComparison.Ordinal) + && string.Equals(result.Owner, segments[0], StringComparison.Ordinal) + && string.Equals(result.Name, segments[1], StringComparison.Ordinal)) + { + return true; + } + } + + result = default; + return false; + } + + bool TryGetRepositoryInfoFromGitRemote(string remote, out (string Remote, string HostUrl, string Owner, string Name) result) + { + if (context.Exec("git", "remote get-url " + remote, out var output) != 0) + { + result = default; + return false; + } + + var url = output.FirstOrDefault(); + if (string.IsNullOrEmpty(url)) + { + result = default; + return false; + } + + Uri uri; + try + { + uri = new Uri(url); + } + catch (UriFormatException) + { + result = default; + return false; + } + + var path = uri.AbsolutePath; + path = path.EndsWith(".git", StringComparison.Ordinal) + ? path.Substring(1, path.Length - 5) + : path.Substring(1); + + var segments = path.Split('/'); + if (segments.Length != 2) + { + result = default; + return false; + } + + result = (remote, $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? null : ":" + uri.Port.ToString())}", segments[0], segments[1]); + return true; + } +} + +/* + * Summary : Tells whether a tag exists in the local Git repository. + * Params : context - The Cake context. + * tag - The tag to check for. + * Returns : True if the tag exists; false otherwise. + */ +static bool GitTagExists(this ICakeContext context, string tag) => context.Exec("git", "tag").Any(s => string.Equals(tag, s, StringComparison.Ordinal)); + +/* + * Summary : Gets the latest version and the latest stable version in commit history. + * Params : context - The Cake context. + * Returns : A tuple of the latest version and the latest stable version; + * Remarks : - If no version tag is found in commit history, this method returns a tuple of two nulls. + * - If no stable version tag is found in commit history, this method returns a tuple of the latest version and null. + */ +static (SemanticVersion? Latest, SemanticVersion? LatestStable) GitGetLatestVersions(this ICakeContext context) +{ + context.Verbose("Looking for latest stable version tag in Git commit history..."); + var output = context.Exec("git", "log --pretty=format:%D"); + var versions = output.Where(static x => !string.IsNullOrEmpty(x)) + .SelectMany(static x => x.Split(", ")) + .Where(static x => x.StartsWith("tag: ")) + .Select(static x => x.Substring(5)) + .Select(static x => { + SemanticVersion? version = null; + var result = SemanticVersion.TryParse(x, out version); + return version; + }) + .Where(static x => x != null); + + SemanticVersion? latest = null; + SemanticVersion? latestStable = null; + foreach (var version in versions) + { + if (latest == null) + { + latest = version; + } + + if (!version.IsPrerelease) + { + latestStable = version; + break; + } + } + + return (latest, latestStable); +} + +/* + * Summary : Sets Git user name and email. + * Params : context - The Cake context. + * name - The name of the user. + * email - The email address of the user. + */ +static void GitSetUserIdentity(this ICakeContext context, string name, string email) +{ + context.Information($"Setting Git user name to '{name}'..."); + _ = context.Exec( + "git", + new ProcessArgumentBuilder() + .Append("config") + .Append("user.name") + .AppendQuoted(name)); + + context.Information($"Setting Git user email to '{email}'..."); + _ = context.Exec( + "git", + new ProcessArgumentBuilder() + .Append("config") + .Append("user.email") + .AppendQuoted(email)); +} diff --git a/build/github.cake b/build/github.cake new file mode 100644 index 0000000..020737e --- /dev/null +++ b/build/github.cake @@ -0,0 +1,151 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#addin nuget:?package=Cake.Http&version=2.0.0 +#addin nuget:?package=Octokit&version=3.0.1 + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// GitHub API helpers +// --------------------------------------------------------------------------------------------- + +using System.Threading.Tasks; +using Octokit; + +using SysFile = System.IO.File; + +/* + * Summary : Asynchronously creates a new draft release on the GitHub repository. + * Params : context - The Cake context. + * data - Build configuration data. + * Returns : A Task, representing the ongoing operation, whose value will be the ID of the newly created release. + */ +static async Task CreateDraftReleaseAsync(this ICakeContext context, BuildData data) +{ + var tag = data.VersionStr; + var client = context.CreateGitHubClient(); + context.Information($"Creating a provisional draft release..."); + var newRelease = new NewRelease(tag) + { + Name = $"{tag} [provisional]", + TargetCommitish = data.Branch, + Prerelease = data.IsPrerelease, + Draft = true, + }; + + var createReleaseResponse = await client.Repository.Release.Create(data.RepositoryOwner, data.RepositoryName, newRelease).ConfigureAwait(false); + return createReleaseResponse.Id; +} + +/* + * Summary : Asynchronously publishes a draft release on the GitHub repository. + * Params : context - The Cake context. + * data - Build configuration data. + * id - The ID of the release. + * Returns : A Task that represents the ongoing operation. + */ +static async Task PublishReleaseAsync(this ICakeContext context, BuildData data, int id) +{ + var tag = data.VersionStr; + var client = context.CreateGitHubClient(); + context.Information($"Generating release notes for {tag}..."); + var releaseNotesRequest = new GenerateReleaseNotesRequest(tag) + { + TargetCommitish = data.Branch, + }; + + var generateNotesResponse = await client.Repository.Release.GenerateReleaseNotes(data.RepositoryOwner, data.RepositoryName, releaseNotesRequest).ConfigureAwait(false); + var body = $"We also have a [human-curated changelog]({data.RepositoryHostUrl}/{data.RepositoryOwner}/{data.RepositoryName}/blob/main/CHANGELOG.md).\n\n---\n\n" + + generateNotesResponse.Body; + + context.Information($"Publishing the previously created release as {tag}..."); + var update = new ReleaseUpdate + { + TagName = tag, + Name = tag, + Body = body, + Prerelease = data.IsPrerelease, + Draft = false, + }; + + _ = await client.Repository.Release.Edit(data.RepositoryOwner, data.RepositoryName, id, update).ConfigureAwait(false); +} + +/* + * Summary : Asynchronously deletes a release and, optionally, the corresponding tag on the GitHub repository. + * Params : context - The Cake context. + * data - Build configuration data. + * id - The ID of the release. + * tagName - The tag name, or null to not delete a tag. + * Returns : A Task that represents the ongoing operation. + */ +static async Task DeleteReleaseAsync(this ICakeContext context, BuildData data, int id, string? tagName) +{ + context.Information("Deleting the previously created release..."); + var client = context.CreateGitHubClient(); + await client.Repository.Release.Delete(data.RepositoryOwner, data.RepositoryName, id).ConfigureAwait(false); + if (tagName != null) + { + var reference = "refs/tags/" + tagName; + context.Information($"Looking for reference '{reference}' in GitHub repository..."); + try + { + _ = await client.Git.Reference.Get(data.RepositoryOwner, data.RepositoryName, reference).ConfigureAwait(false); + } + catch (NotFoundException) + { + context.Information($"Reference '{reference}' not found in GitHub repository."); + return; + } + + context.Information($"Deleting reference '{reference}' in GitHub repository..."); + await client.Git.Reference.Delete(data.RepositoryOwner, data.RepositoryName, reference).ConfigureAwait(false); + } +} + +/* + * Summary : Asynchronously creates a workflow dispatch event on the GitHub repository. + * Params : context - The Cake context. + * data - Build configuration data. + * filename - The name of the workflow file to run, including extension. + * ref - The branch or tag on which to dispatch the workflow run. + * inputs - An optional anonymous object containing the inputs for the workflow. + * Returns : A Task that represents the ongoing operation. + */ +static async Task DispatchWorkflow(this ICakeContext context, BuildData data, string filename, string @ref, object? inputs = null) +{ + context.Information($"Dispatching workflow '{filename}' on '{@ref}'..."); + object requestBody = inputs == null + ? new { @ref = @ref } + : new { @ref = @ref, inputs = inputs }; + + var httpSettings = new HttpSettings() + .SetAccept("application/vnd.github.v3") + .AppendHeader("Authorization", "Token " + context.GetOptionOrFail("githubToken")) + .AppendHeader("User-Agent", "Buildvana (Win32NT 10.0.19044; amd64; en-US)") + .SetJsonRequestBody(requestBody) + .EnsureSuccessStatusCode(true); + + _ = await context.HttpPostAsync($"https://api.github.com/repos/{data.RepositoryOwner}/{data.RepositoryName}/actions/workflows/{filename}/dispatches", httpSettings); +} + +/* + * Summary : Sets a GitHub Actions step output. + * Params : context - The Cake context. + * name - The output name. + * value - The output value. + */ +static void SetActionsStepOutput(this ICakeContext context, string name, string value) +{ + var outputFile = context.EnvironmentVariable("GITHUB_OUTPUT"); + Ensure(!string.IsNullOrEmpty(outputFile), "Cannot set Actions step output: GITHUB_OUTPUT not set."); + SysFile.AppendAllLines(outputFile, new[] { $"{name}={value}" }, Encoding.UTF8); +} + +static GitHubClient CreateGitHubClient(this ICakeContext context) +{ + var client = new GitHubClient(new ProductHeaderValue("Buildvana")); + client.Credentials = new Credentials(context.GetOptionOrFail("githubToken")); + return client; +} diff --git a/build/json.cake b/build/json.cake new file mode 100644 index 0000000..a6f6e03 --- /dev/null +++ b/build/json.cake @@ -0,0 +1,137 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// JSON helpers +// --------------------------------------------------------------------------------------------- + +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +using SysFile = System.IO.File; + +/* + * Summary : Parses a JSON object from a string. Fails the build if not successful. + * Params : str - The string to parse. + * description - A description of the string for exception messages. + * Returns : The parsed object. + */ +static JsonObject ParseJsonObject(string str, string description = "The provided string") +{ + JsonNode? node; + try + { + node = JsonNode.Parse( + str, + new JsonNodeOptions { PropertyNameCaseInsensitive = false }, + new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }); + } + catch (JsonException) + { + Fail($"{description} is not valid JSON."); + throw null; + } + + return node switch { + null => Fail($"{description} was parsed as JSON null."), + JsonObject obj => obj, + object other => Fail($"{description} was parsed as a {other.GetType().Name}, not a {nameof(JsonObject)}."), + }; +} + +/* + * Summary : Loads a JSON object from a file. Fails the build if not successful. + * Params : path - The path of the file to parse. + * Returns : The parsed object. + */ +static JsonObject LoadJsonObject(FilePath path) +{ + var fullPath = path.FullPath; + JsonNode? node; + try + { + using var stream = SysFile.OpenRead(fullPath); + node = JsonNode.Parse( + stream, + new JsonNodeOptions { PropertyNameCaseInsensitive = false }, + new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }); + } + catch (IOException e) + { + Fail($"Could not read from {fullPath}: {e.Message}"); + throw null; + } + catch (JsonException) + { + Fail($"{fullPath} does not contain valid JSON."); + throw null; + } + + return node switch { + null => Fail($"{fullPath} was parsed as JSON null."), + JsonObject obj => obj, + object other => Fail($"{fullPath} was parsed as a {other.GetType().Name}, not a {nameof(JsonObject)}."), + }; +} + +/* + * Summary : Saves a JSON object to a file. Fails the build if not successful. + * Params : path - The path of the file to parse. + * Returns : The parsed object. + */ +static void SaveJson(JsonNode json, FilePath path) +{ + var fullPath = path.FullPath; + try + { + using var stream = SysFile.OpenWrite(fullPath); + var writerOptions = new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = true, + }; + + using var writer = new Utf8JsonWriter(stream, writerOptions); + json.WriteTo(writer); + stream.SetLength(stream.Position); + } + catch (IOException e) + { + Fail($"Could not write to {fullPath}: {e.Message}"); + throw null; + } +} + +/* + * Summary : Gets the value of a property from a JSON object. Fails the build if not successful. + * Types : T - The desired type of the property value. + * Params : json - The JSON object. + * propertyName - The name of the property to get. + * description - A description of the object for exception messages. + * Returns : The value of the specified property. + */ +static T GetJsonPropertyValue(JsonObject json, string propertyName, string objectDescription = "JSON object") +{ + Ensure(json.TryGetPropertyValue(propertyName, out var property), $"Json property {propertyName} not found in {objectDescription}."); + switch (property) + { + case null: + return Fail($"Json property {propertyName} in {objectDescription} is null."); + case JsonValue value: + Ensure(value.TryGetValue(out var result), $"Json property {propertyName} in {objectDescription} cannot be converted to a {typeof(T).Name}."); + return result ?? Fail($"Json property {propertyName} in {objectDescription} has a null value."); + default: + return Fail($"Json property {propertyName} in {objectDescription} is a {property.GetType().Name}, not a {nameof(JsonValue)}."); + } +} diff --git a/build/nbgv.cake b/build/nbgv.cake new file mode 100644 index 0000000..10277c1 --- /dev/null +++ b/build/nbgv.cake @@ -0,0 +1,41 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Nerdbank.GitVersioning helpers +// --------------------------------------------------------------------------------------------- + +using System.Text; +using System.Text.Json.Nodes; + +/* + * Summary : Gets version information using the NBGV tool. + * Params : context - The Cake context. + * Returns : VersionStr - The project version. + * Ref - The Git ref from which we are building. + * IsPublicRelease - True if a public release can be built, false otherwise. + * IsPrerelease - True if the project version is tagged as prerelease, false otherwise. + */ +static (string VersionStr, string Ref, bool IsPublicRelease, bool IsPrerelease) GetVersionInformation(this ICakeContext context) +{ + var nbgvOutput = new StringBuilder(); + context.DotNetTool( + "nbgv get-version --format json", + new DotNetToolSettings { + SetupProcessSettings = s => s + .SetRedirectStandardOutput(true) + .SetRedirectedStandardOutputHandler(x => { + nbgvOutput.AppendLine(x); + return x; + }), + }); + + var json = ParseJsonObject(nbgvOutput.ToString(), "The output of nbgv"); + return ( + VersionStr: GetJsonPropertyValue(json, "NuGetPackageVersion", "the output of nbgv"), + Ref: GetJsonPropertyValue(json, "BuildingRef", "the output of nbgv"), + IsPublicRelease: GetJsonPropertyValue(json, "PublicRelease", "the output of nbgv"), + IsPrerelease: !string.IsNullOrEmpty(GetJsonPropertyValue(json, "PrereleaseVersion", "the output of nbgv"))); +} diff --git a/build/options.cake b/build/options.cake new file mode 100644 index 0000000..27d06f5 --- /dev/null +++ b/build/options.cake @@ -0,0 +1,144 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Option helpers +// --------------------------------------------------------------------------------------------- + +using System.ComponentModel; +using System.Linq; + +/* + * Summary : Tells whether the specified option is present, either as an argument + * or as an environment variable. + * Params : context - The Cake context. + * name - The option name. + * environmentPrefix - An optional prefix for the environment variable name; + * for example, camelCasedOption (prefix = "MYAPP_") -> MYAPP_CAMEL_CASED_OPTION + * Returns : If an argument with the specified name is present, true; + * if an environment variable with the specified name (converted according to environmentPrefix) + * is present, true; otherwise, false. + */ +static bool HasOption(this ICakeContext context, string name, string? environmentPrefix = null) + => context.HasArgument(name) || context.HasEnvironmentVariable(OptionNameToEnvironmentVariableName(name, environmentPrefix)); + +/* + * Summary : Gets an option from, in this order: + * * a command line argument with the specified name; + * * an environment variable with the specified name converted to UNDERSCORE_UPPER_CASE; + * * the provided default value. + * Params : context - The Cake context. + * name - The option name. + * defaultValue - The value returned if neither a corresponding argument + * nor environment variable was found. + */ +static T GetOption(this ICakeContext context, string name, T defaultValue) + where T : notnull + => context.GetOption(name, null, defaultValue); + +/* + * Summary : Gets an option from, in this order: + * * a command line argument with the specified name; + * * an environment variable with the specified name converted to UNDERSCORE_UPPER_CASE + * and optionally prefixed with the specified environmentPrefix; + * * the provided default value. + * Params : context - The Cake context. + * name - The option name. + * environmentPrefix - An optional prefix for the environment variable name; + * for example, "camelCasedOption" with an environmentPrefix + * of "MYAPP_" becomes "MYAPP_CAMEL_CASED_OPTION". + * defaultValue - The value returned if neither a corresponding argument + * nor environment variable was found. + */ +static T GetOption(this ICakeContext context, string name, string? environmentPrefix, T defaultValue) + where T : notnull +{ + var value = context.Arguments.GetArguments(name)?.FirstOrDefault(); + if (value != null) + { + return ConvertOption(value); + } + + value = context.Environment.GetEnvironmentVariable(OptionNameToEnvironmentVariableName(name, environmentPrefix)); + return value == null ? defaultValue : ConvertOption(value); +} + +/* + * Summary : Gets an option from, in this order: + * * a command line argument with the specified name; + * * an environment variable with the specified name converted to UNDERSCORE_UPPER_CASE; + * * the provided default value. + * Throw an exception if the option is not found or has an empty value. + * Params : context - The Cake context. + * name - The option name. + */ +static T GetOptionOrFail(this ICakeContext context, string name) + where T : notnull + => context.GetOptionOrFail(name, null); + +/* + * Summary : Gets an option from, in this order: + * * a command line argument with the specified name; + * * an environment variable with the specified name converted to UNDERSCORE_UPPER_CASE + * and optionally prefixed with the specified environmentPrefix; + * * the provided default value. + * Throw an exception if the option is not found or has an empty value. + * Params : context - The Cake context. + * name - The option name. + * environmentPrefix - An optional prefix for the environment variable name; + * for example, "camelCasedOption" with an environmentPrefix + * of "MYAPP_" becomes "MYAPP_CAMEL_CASED_OPTION". + */ +static T GetOptionOrFail(this ICakeContext context, string name, string? environmentPrefix) + where T : notnull +{ + var value = context.Arguments.GetArguments(name)?.FirstOrDefault(); + if (value != null) + { + return ConvertOption(value); + } + + var envName = OptionNameToEnvironmentVariableName(name, environmentPrefix); + value = context.Environment.GetEnvironmentVariable(envName); + if (value != null) + { + return ConvertOption(value); + } + + throw new CakeException($"Option {name} / environment variable {envName} not found or empty."); +} + +/* + * Summary : Converts an option name (which is supposed to be in camelCase) + * to an environment variable name (UNDERSCORE_UPPER_CASE). + * Params : prefix - An optional prefix for the environment variable name; + * for example, camelCasedOption (prefix = "MYAPP_") -> MYAPP_CAMEL_CASED_OPTION + */ +// Copyright (c) .NET Foundation and Contributors - MIT License - https://github.com/Humanizr/Humanizer +static string OptionNameToEnvironmentVariableName(string name, string? prefix = null) + => (prefix ?? string.Empty) + Regex.Replace( + Regex.Replace( + Regex.Replace( + name, + @"([\p{Lu}]+)([\p{Lu}][\p{Ll}])", + "$1_$2"), + @"([\p{Ll}\d])([\p{Lu}])", + "$1_$2"), + @"[-\s]", + "_") + .ToUpperInvariant(); + +/* + * Summary : Convert an option to the desired type. + * Types : T - The type to convert the option to. + * Params : value - The value of the option. + * Returns : The converted value. + */ +static T ConvertOption(string value) + where T : notnull +{ + var converter = TypeDescriptor.GetConverter(typeof(T)); + return (T)converter.ConvertFromInvariantString(value)!; +} diff --git a/build/process.cake b/build/process.cake new file mode 100644 index 0000000..0164906 --- /dev/null +++ b/build/process.cake @@ -0,0 +1,38 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Process helpers +// --------------------------------------------------------------------------------------------- + +using System.Collections.Generic; + +/* + * Summary : Executes an external command, capturing standard output and failing if the exit code is not zero. + * Params : context - The Cake context. + * command - The name of the command to execute. + * arguments - The arguments to pass to the command. + * Returns : The captured output of the command. + */ +static IEnumerable Exec(this ICakeContext context, string command, ProcessArgumentBuilder arguments) +{ + var exitCode = context.Exec(command, arguments, out var output); + Ensure(exitCode == 0, $"'{command} {arguments.RenderSafe()}' exited with code {exitCode}."); + return output; +} + +/* + * Summary : Executes an external command, capturing standard output and failing if the exit code is not zero. + * Params : context - The Cake context. + * command - The name of the command to execute. + * arguments - The arguments to pass to the command. + * out output - The captured output of the command. + * Returns : The exit code of the command. + */ +static int Exec(this ICakeContext context, string command, ProcessArgumentBuilder arguments, out IEnumerable output) + => context.StartProcess( + command, + new ProcessSettings { Arguments = arguments, RedirectStandardOutput = true }, + out output); diff --git a/build/public-api.cake b/build/public-api.cake new file mode 100644 index 0000000..fd1dfbe --- /dev/null +++ b/build/public-api.cake @@ -0,0 +1,181 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Public API helpers +// --------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Linq; +using System.Text; + +using SysFile = System.IO.File; + +/* + * Summary : Specifies the kind of changes public APIs have undergone between an older and a newer version. + * Remarks : The values of this enum are sorted in ascending order of importance, + * so that they may be compared. + */ +enum ApiChangeKind +{ + /* + * Summary : Public APIs have not changed between two versions. + */ + None, + + /* + * Summary : A newer version has only added public APIs with respect to an older version. + */ + Additive, + + /* + * Summary : A newer version's public APIs have undergone breaking changes since an older version was published. + */ + Breaking, +} + +/* + * Summary : Gets the kind of change public APIs underwent, according to the presence of new public APIs + * and/or the removal of existing public APIs in all PublicAPI.Unshipped.txt files + * of the repository. + * Params : context - The Cake context. + * Returns : If at least one public API was removed, ApiChangeKind.Breaking; + * if no public API was removed, but at least one was added, ApiChangeKind.Additive; + * if no public API was removed nor added, ApiChangeKind.None. + */ +static ApiChangeKind GetPublicApiChangeKind(this ICakeContext context) +{ + context.Information("Computing API change kind according to unshipped public API files..."); + var result = ApiChangeKind.None; + foreach (var unshippedPath in context.GetAllPublicApiFilePairs().Select(pair => pair.UnshippedPath)) + { + var fileResult = context.GetPublicApiChangeKind(unshippedPath); + context.Verbose($"{unshippedPath} -> {fileResult}"); + if (fileResult == ApiChangeKind.Breaking) + { + return ApiChangeKind.Breaking; + } + else if (fileResult > result) + { + result = fileResult; + } + } + + return result; +} + +/* + * Summary : Transfers unshipped public API definitions to PublicAPI.Shipped.txt + * in all directories of the repository. + * Params : context - The Cake context. + * Returns : An enumeration of the modified files. + */ +static IEnumerable TransferAllPublicApiToShipped(this ICakeContext context) +{ + context.Information("Updating public API files..."); + foreach (var pair in context.GetAllPublicApiFilePairs()) + { + context.Verbose($"Updating {pair.ShippedPath}..."); + if (context.TransferPublicApiToShipped(pair.UnshippedPath, pair.ShippedPath)) + { + yield return pair.ShippedPath; + yield return pair.UnshippedPath; + } + } +} + +/* + * Summary : Gets all public API definition file pairs in the repository. + * Params : context - The Cake context. + * Returns : An enumeration of (UnshippedPath, ShippedPath) tuples. + */ +static IEnumerable<(FilePath UnshippedPath, FilePath ShippedPath)> GetAllPublicApiFilePairs(this ICakeContext context) +{ + (FilePath UnshippedPath, FilePath ShippedPath)? GetPair(FilePath shippedPath) + { + var unshippedPath = shippedPath.GetDirectory().CombineWithFilePath("PublicAPI.Unshipped.txt"); + return context.FileSystem.Exist(unshippedPath) ? (unshippedPath, shippedPath) : null; + } + + return context + .GetFiles("**/PublicAPI.Shipped.txt", new() { IsCaseSensitive = true }) + .Select(GetPair) + .Where(maybePair => maybePair.HasValue) + .Select(maybePair => maybePair!.Value); +} + +/* + * Summary : Gets the kind of change public APIs underwent, according to the presence of new public APIs + * and/or the removal of existing public APIs. + * Params : context - The Cake context. + * unshippedPath - The FilePath of PublicAPI.Unshipped.txt + * Returns : If at least one public API was removed, ApiChangeKind.Breaking; + * if no public API was removed, but at least one was added, ApiChangeKind.Additive; + * if no public API was removed nor added, ApiChangeKind.None. + */ +static ApiChangeKind GetPublicApiChangeKind(this ICakeContext context, FilePath unshippedPath) +{ + var unshippedLines = SysFile.ReadAllLines(unshippedPath.FullPath, Encoding.UTF8); + static bool IsEmptyOrStartsWithHash(string s) => s.Length == 0 || s[0] == '#'; + var unshippedPublicApiLines = unshippedLines.SkipWhile(IsEmptyOrStartsWithHash); + const string RemovedPrefix = "*REMOVED*"; + var newApiPresent = false; + foreach (var line in unshippedPublicApiLines) + { + if (line.StartsWith(RemovedPrefix, StringComparison.Ordinal)) + { + return ApiChangeKind.Breaking; + } + + newApiPresent = true; + } + + return newApiPresent ? ApiChangeKind.Additive : ApiChangeKind.None; +} + +/* + * Summary : Transfers unshipped public API definitions to PublicAPI.Shipped.txt + * Params : context - The Cake context. + * unshippedPath - The FilePath of PublicAPI.Unshipped.txt + * shippedPath - The FilePath of PublicAPI.Shipped.txt + * Returns : true if files were modified; false otherwise. + */ +static bool TransferPublicApiToShipped(this ICakeContext context, FilePath unshippedPath, FilePath shippedPath) +{ + var utf8 = new UTF8Encoding(false); + var unshippedLines = SysFile.ReadAllLines(unshippedPath.FullPath, utf8); + var unshippedHeaderLines = unshippedLines.TakeWhile(IsEmptyOrStartsWithHash).ToArray(); + if (unshippedHeaderLines.Length == unshippedLines.Length) + { + return false; + } + + static bool IsEmptyOrStartsWithHash(string s) => s.Length == 0 || s[0] == '#'; + var shippedLines = SysFile.ReadAllLines(shippedPath.FullPath, utf8); + var shippedHeaderLines = shippedLines.TakeWhile(IsEmptyOrStartsWithHash).ToArray(); + + const string RemovedPrefix = "*REMOVED*"; + static bool StartsWithRemovedPrefix(string s) => s.StartsWith(RemovedPrefix, StringComparison.Ordinal); + static bool DoesNotStartWithRemovedPrefix(string s) => !StartsWithRemovedPrefix(s); + var removedLines = unshippedLines + .Skip(unshippedHeaderLines.Length) + .Where(StartsWithRemovedPrefix) + .Select(l => l[(RemovedPrefix.Length)..]) + .OrderBy(l => l, StringComparer.Ordinal) // For BinarySearch + .ToArray(); + + bool IsNotRemoved(string s) => Array.BinarySearch(removedLines, s, StringComparer.Ordinal) < 0; + var newShippedLines = shippedLines + .Skip(shippedHeaderLines.Length) + .Where(IsNotRemoved) + .Concat(unshippedLines + .Skip(unshippedHeaderLines.Length) + .Where(DoesNotStartWithRemovedPrefix)) + .OrderBy(l => l, StringComparer.Ordinal); + + SysFile.WriteAllLines(shippedPath.FullPath, shippedHeaderLines.Concat(newShippedLines), utf8); + SysFile.WriteAllLines(unshippedPath.FullPath, unshippedHeaderLines, utf8); + return true; +} diff --git a/build/setup-teardown.cake b/build/setup-teardown.cake new file mode 100644 index 0000000..10edfb4 --- /dev/null +++ b/build/setup-teardown.cake @@ -0,0 +1,33 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// ============================================================================================= +// Setup and Teardown, common to all scripts +// ============================================================================================= + +Setup(context => +{ + var data = new BuildData(context); + if (data.IsCI && !data.IsGitHubAction) + { + throw new CakeException(255, "This script can only run locally or in a GitHub Actions workflow."); + } + + return data; +}); + +Teardown((context, data) => +{ + // For some reason, DotNetBuildServerShutdown hangs in a GitHub Actions runner; + // it is still useful on a local machine though + if (!data.IsCI) + { + context.DotNetBuildServerShutdown(new DotNetBuildServerShutdownSettings + { + Razor = true, + VBCSCompiler = true, + }); + } +}); diff --git a/build/versioning.cake b/build/versioning.cake new file mode 100644 index 0000000..521da75 --- /dev/null +++ b/build/versioning.cake @@ -0,0 +1,362 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +// Do not use #addin because the assembly is distributed within Cake.Tool +#r NuGet.Versioning + +#nullable enable + +using NuGet.Versioning; + +// --------------------------------------------------------------------------------------------- +// Version management helpers +// --------------------------------------------------------------------------------------------- + +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +/* + * Summary : Specifies how to modify the version specification upon publishing a release. + */ +enum VersionSpecChange +{ + /* + * Summary : Do not force a version increment; do not modify the unstable tag. + */ + None, + + /* + * Summary : Do not force a version increment; add an unstable tag if not present. + */ + Unstable, + + /* + * Summary : Do not force a version increment; remove the unstable tag if present. + */ + Stable, + + /* + * Summary : Force a minor version increment with respect to the latest stable version; add an unstable tag. + */ + Minor, + + /* + * Summary : Force a major version increment and minor version reset with respect to the latest stable version; add an unstable tag. + */ + Major, +} + +/* + * Summary : Specifies a kind of version increment. + * Remarks : The values of this enum are sorted in ascending order of importance, + * so that they may be compared. + */ +enum VersionIncrement +{ + /* + * Summary : Represents no version advancement. + */ + None, + + /* + * Summary : Represents the increment of minor version. + */ + Minor, + + /* + * Summary : Represents the increment of major version and reset of minor version. + */ + Major, +} + +/* + * Summary : Represents a Major.Minor[-Tag] version as found in version.json. + */ +sealed record VersionSpec +{ + private static readonly Regex VersionSpecRegex = new Regex( + @"(?-imsx)^v?(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(-(?.*))?$", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture); + + private VersionSpec(int major, int minor, string tag) + { + Major = major; + Minor = minor; + Tag = tag; + } + + /* + * Summary : Gets the major version. + */ + public int Major { get; } + + /* + * Summary : Gets the minor version. + */ + public int Minor { get; } + + /* + * Summary : Gets current unstable tag. + * Value : The current unstable tag, or the empty string if the current version is stable. + */ + public string Tag { get; } + + /* + * Summary : Gets a value indicating whether this instance has an unstable tag. + */ + public bool HasTag => !string.IsNullOrEmpty(Tag); + + /* + * Summary : Attempts to parse a VersionSpec from the specified string. + * Params : str - The string to parse. + * result - When this method returns true, the parsed VersionSpec. + * Returns : True is successful; false otherwise. + */ + public static bool TryParse(string str, [MaybeNullWhen(false)] out VersionSpec result) + { + var match = VersionSpecRegex.Match(str); + if (!match.Success) + { + result = null; + return false; + } + + result = new( + int.Parse(match.Groups["major"].Value), + int.Parse(match.Groups["minor"].Value), + match.Groups["tag"].Value + ); + + return true; + } + + public override string ToString() => $"{Major}.{Minor}{(HasTag ? "-" + Tag : null)}"; + + /* + * Summary : Gets an instance of VersionSpec that represents the same version as the current instance + * and has no unstable tag. + * Returns : If this instance has no unstable tag, this instance; otherwise, a newly-constructed VersionSpec + * that represents the same version as the current instance and has no unstable tag. + */ + public VersionSpec Stable() => HasTag ? new(Major, Minor, string.Empty) : this; + + /* + * Summary : Gets an instance of VersionSpec that represents the same version as the current instance + * and has the specified unstable tag. + * Params : tag - The unstable tag of the returned instance. + * Returns : If this instance's Tag property is equal to the given tag, this instance; otherwise, a newly-constructed VersionSpec + * that represents the same version as the current instance and has the specified unstable tag. + */ + public VersionSpec Unstable(string tag) => string.Equals(Tag, tag, StringComparison.Ordinal) ? this : new(Major, Minor, tag); + + /* + * Summary : Gets an instance of VersionSpec that represents the next minor version with respect to the current instance + * and has the specified unstable tag. + * Params : tag - The unstable tag of the returned instance. + * Returns : A newly-constructed VersionSpec. + */ + public VersionSpec NextMinor(string tag) => new(Major, Minor + 1, tag); + + /* + * Summary : Gets an instance of VersionSpec that represents the next major version with respect to the current instance + * and has the specified unstable tag. + * Params : tag - The unstable tag of the returned instance. + * Returns : A newly-constructed VersionSpec. + */ + public VersionSpec NextMajor(string tag) => new(Major + 1, 0, tag); + + /* + * Summary : Gets an instance of VersionSpec that represents the result of applying the specified change + * to the current instance. + * Params : action - An enumeration value representing the kind of change to apply. + * tag - If the returned instance has an unstable tag, the unstable tag of the returned instance; + * otherwise, this parameter is ignored. + * Returns : Result - The result of applying action to the current instance. + * Changed - If Result is equal to the current instance, false; otherwise, true. + */ + public (VersionSpec Result, bool Changed) ApplyChange(VersionSpecChange change, string tag) + => change switch { + VersionSpecChange.Unstable => HasTag ? (this, false) : (Unstable(tag), true), + VersionSpecChange.Stable => HasTag ? (Stable(), true) : (this, false), + VersionSpecChange.Minor => (NextMinor(tag), true), + VersionSpecChange.Major => (NextMajor(tag), true), + _ => (this, false), + }; +} + +/* + * Summary : Represents the version.json file, for the purpose of applying version advances. + */ +sealed class VersionFile +{ + private const string VersionJsonPath = "version.json"; + private const string DefaultFirstUnstableTag = "preview"; + + private readonly JsonNode _json; + + private VersionFile(FilePath path, JsonNode json, VersionSpec versionSpec, string firstUnstableTag) + { + Path = path; + _json = json; + VersionSpec = versionSpec; + FirstUnstableTag = firstUnstableTag; + } + + /* + * Summary : Gets the FilePath of the version.json file. + */ + public FilePath Path { get; } + + /* + * Summary : Gets a VersionSpec representing the "version" value in the version.json file. + */ + public VersionSpec VersionSpec { get; private set; } + + /* + * Summary : Gets the unstable tag to use for version advances. + * Value : Either the "release.firstUnstableTag" value read from version.json, + * or "preview" as a default value. + */ + public string FirstUnstableTag { get; private init; } + + /* + * Summary : Constructs a VersionFile instance by loading the repository's version.json file. + * Returns : A newly-constructed instance of VersionFile, representing the loaded data. + */ + public static VersionFile Load() + { + var path = new FilePath(VersionJsonPath); + var json = LoadJsonObject(path); + var versionStr = GetJsonPropertyValue(json, "version", path + " file"); + Ensure(VersionSpec.TryParse(versionStr, out var versionSpec), $"{VersionJsonPath} contains invalid version specification '{versionStr}'."); + var firstUnstableTag = DefaultFirstUnstableTag; + var release = json["release"]; + if (release is not null) + { + var firstUnstableTagNode = release["firstUnstableTag"]; + if (firstUnstableTagNode is JsonValue firstUnstableTagValue && firstUnstableTagValue.TryGetValue(out var firstUnstableTagStr) && !string.IsNullOrEmpty(firstUnstableTagStr)) + { + firstUnstableTag = firstUnstableTagStr; + } + } + + return new(path, json, versionSpec, firstUnstableTag); + } + + /* + * Summary : Applies a version spec change to this instance. + * Params : context - The Cake context. + * change - An enumeration value representing the kind of change to apply. + * Returns : If the VersionSpec property is actually changed as a result of change, true; otherwise, false. + * Remarks : - This method does not save the modified version.json file; you will have to call the Save method + * if this method returns true. + */ + public bool ApplyVersionSpecChange(ICakeContext context, VersionSpecChange change) + { + var previousVersionSpec = VersionSpec; + (VersionSpec, var changed) = VersionSpec.ApplyChange(change, FirstUnstableTag); + if (changed) + { + context.Information($"Version spec changed from {previousVersionSpec} to {VersionSpec}."); + } + else + { + context.Information("Version spec not changed."); + } + + return changed; + } + + /* + * Summary : Saves the version.json file, possibly with a modified VersionSpec, back to the repository. + */ + public void Save() + { + _json["version"] = JsonValue.Create(VersionSpec.ToString()); + SaveJson(_json, Path); + } +} + +/* + * Summary : Computes the VersionSpecChange to apply upon release. + * Params : context - The Cake context. + * currentVersion - The current version as computed by NBGV + * requestedChange - The version spec change requested by the user + * checkPublicApi - If true, account for changes in public API files. + * Returns : The actual change to apply . + */ +static VersionSpecChange ComputeVersionSpecChange( + this ICakeContext context, + SemanticVersion currentVersion, + VersionSpecChange requestedChange, + bool checkPublicApi) +{ + // Throw if versions are messed up + var (latestVersion, latestStableVersion) = context.GitGetLatestVersions(); + context.Information($"Latest version is {latestVersion?.ToString() ?? "(none)"}"); + context.Information($"Latest stable version is {latestStableVersion?.ToString() ?? "(none)"}"); + Ensure( + VersionComparer.Compare(currentVersion, latestStableVersion, VersionComparison.Version) > 0, + $"Versioning anomaly detected: current version ({currentVersion}) is not higher than than latest stable version ({latestStableVersion?.ToString() ?? "none"})."); + Ensure( + VersionComparer.Compare(currentVersion, latestStableVersion, VersionComparison.Version) > 0, + $"Versioning anomaly detected: latest version ({latestVersion?.ToString() ?? "none"}) is not higher than than latest stable version ({latestStableVersion?.ToString() ?? "none"})."); + Ensure( + VersionComparer.Compare(currentVersion, latestVersion, VersionComparison.Version) > 0, + $"Versioning anomaly detected: current version ({currentVersion}) is not higher than than last stable version ({latestStableVersion?.ToString() ?? "none"})."); + + // Determine how we are currently already incrementing version + var currentVersionIncrement = latestStableVersion == null ? VersionIncrement.Major + : currentVersion.Major > latestStableVersion.Major ? VersionIncrement.Major + : currentVersion.Minor > latestStableVersion.Minor ? VersionIncrement.Minor + : VersionIncrement.None; + context.Information($"Current version increment: {currentVersionIncrement}"); + + // Determine the kind of change in public API + var publicApiChangeKind = checkPublicApi ? context.GetPublicApiChangeKind() : ApiChangeKind.None; + context.Information($"Public API change kind: {publicApiChangeKind}{(checkPublicApi ? null : " (not checked)")}"); + + // Determine the version increment required by SemVer rules + var isInitialDevelopmentPhase = latestStableVersion == null || latestStableVersion.Major == 0; + var semanticVersionIncrement = publicApiChangeKind switch { + ApiChangeKind.Breaking => isInitialDevelopmentPhase ? VersionIncrement.Minor : VersionIncrement.Major, + ApiChangeKind.Additive => isInitialDevelopmentPhase ? VersionIncrement.None : VersionIncrement.Minor, + _ => VersionIncrement.None, + }; + context.Information($"Required version increment according to Semantic Versioning rules: {semanticVersionIncrement}"); + + // Determine the requested version increment, if any. + context.Information($"Requested version spec change: {requestedChange}"); + var requestedVersionIncrement = requestedChange switch { + VersionSpecChange.Major => VersionIncrement.Major, + VersionSpecChange.Minor => VersionIncrement.Minor, + _ => VersionIncrement.None, + }; + context.Information($"Requested version increment: {requestedVersionIncrement}."); + + // Adjust requested version increment to follow SemVer rules + if (semanticVersionIncrement > requestedVersionIncrement) + { + requestedVersionIncrement = semanticVersionIncrement; + } + + // Determine the kind of version increment actually required + var actualVersionIncrement = requestedVersionIncrement > currentVersionIncrement ? requestedVersionIncrement : VersionIncrement.None; + context.Information($"Required version increment with respect to current version: {actualVersionIncrement}"); + + // Determine the actual version spec change to apply: + // - forget any increment-related change (already accounted for via requestedVersionIncrement) + // - set the change to the required increment if any, otherwise leave it as is (None, Unstable, Stable) + var actualChange = requestedChange switch { + VersionSpecChange.Major or VersionSpecChange.Minor => VersionSpecChange.None, + _ => requestedChange, + }; + actualChange = actualVersionIncrement switch { + VersionIncrement.Major => VersionSpecChange.Major, + VersionIncrement.Minor => VersionSpecChange.Minor, + _ => actualChange, + }; + context.Information($"Actual version spec change: {actualChange}."); + + return actualChange; +} diff --git a/build/workspace.cake b/build/workspace.cake new file mode 100644 index 0000000..d0885be --- /dev/null +++ b/build/workspace.cake @@ -0,0 +1,28 @@ +// Copyright (C) Tenacom and contributors. Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +#nullable enable + +// --------------------------------------------------------------------------------------------- +// Workspace helpers +// --------------------------------------------------------------------------------------------- + +/* + * Summary : Delete all intermediate and output directories. + * On a local machine, also delete Visual Studio and ReSharper caches. + * Params : context - The Cake context. + */ +static void CleanAll(this ICakeContext context, BuildData data) +{ + context.DeleteDirectoryIfExists(".vs"); + context.DeleteDirectoryIfExists("_ReSharper.Caches"); + context.DeleteDirectoryIfExists("artifacts"); + context.DeleteDirectoryIfExists("logs"); + context.DeleteDirectoryIfExists("TestResults"); + foreach (var project in data.Solution.Projects) + { + var projectDirectory = project.Path.GetDirectory(); + context.DeleteDirectoryIfExists(projectDirectory.Combine("bin")); + context.DeleteDirectoryIfExists(projectDirectory.Combine("obj")); + } +} diff --git a/global.json b/global.json index 64c9698..c97c814 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Buildvana.Sdk": "1.0.0-alpha.20", + "Buildvana.Sdk": "1.0.0-alpha.23", "Microsoft.Build.NoTargets": "3.6.0" } } diff --git a/graphics/PackageIcon.png b/graphics/PackageIcon.png index b05ba41..9aa96a5 100644 Binary files a/graphics/PackageIcon.png and b/graphics/PackageIcon.png differ diff --git a/graphics/README.md b/graphics/README.md new file mode 100644 index 0000000..1212257 --- /dev/null +++ b/graphics/README.md @@ -0,0 +1,49 @@ +# Graphics + +All the graphic elements listed below, except where otherwise specified, are Copyright (C) Tenacom and contributors and are licensed under the MIT license. See the LICENSE file in the project root for full license information. + +## `SquareLogo.svg` + +Reference logo, basic square logo. Used as NuGet package icon, favicon for web-based documentation, and anywhere a square-shaped logo is needed or preferred. + +This is a modifed version of [Peace](#peace). Modified by [@rdeago](https://github.com/rdeago). + +Related files: + +- `PackageIcon.png` (512x512px) + +## `Readme.svg` + +Graphic header for README file. + +Uses the following material: [SquareLogo](#squarelogo); [Repo](#repo). + +Related files: + +- `Readme.png` (720x160px) + +## `SocialCard.svg` + +Social card for GitHub project. + +Uses the following material: [SquareLogo](#squarelogo); [Repo](#repo); [Courier Prime](#courier-prime). + +Related files: + +- `SocialCard.png` (1280x640px) + +## Third-party material + +The following third-party material was used to produce the above files. + +### Peace + +The [Peace](https://thenounproject.com/icon/1951204) vector picture, by AomAm, was obtained from [the Noun Project](https://thenounproject.com/) under the [Creative Commons Attribution 3.0 Unported (CC BY 3.0)](https://creativecommons.org/licenses/by/3.0/) license. + +### Repo + +The [Repo](https://fontlibrary.org/en/font/repo) font, by Stefan Peev, was obtained from [Font Library](https://fontlibrary.org) under the [SIL Open Font License (OFL)](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL). + +### Courier Prime + +The [Courier Prime](https://fontlibrary.org/en/font/courier-prime) font, by Alan Dague-Greene of Quote-Unquote Apps, was obtained from [Font Library](https://fontlibrary.org) under the [SIL Open Font License (OFL)](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL). diff --git a/graphics/Readme.png b/graphics/Readme.png new file mode 100644 index 0000000..b106aba Binary files /dev/null and b/graphics/Readme.png differ diff --git a/graphics/Readme.svg b/graphics/Readme.svg new file mode 100644 index 0000000..7959ed7 --- /dev/null +++ b/graphics/Readme.svg @@ -0,0 +1,113 @@ + + + + + + image/svg+xml + + + + + + + + + Buildvana SDK + + + + + Develop in a state of bliss + diff --git a/graphics/SocialCard.png b/graphics/SocialCard.png new file mode 100644 index 0000000..f56c6ce Binary files /dev/null and b/graphics/SocialCard.png differ diff --git a/graphics/SocialCard.svg b/graphics/SocialCard.svg new file mode 100644 index 0000000..f5b8444 --- /dev/null +++ b/graphics/SocialCard.svg @@ -0,0 +1,163 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + Buildvana SDK + Develop in a state of bliss + + + + + https://github.com/Tenacom/Buildvana.Sdk + diff --git a/graphics/SquareLogo.svg b/graphics/SquareLogo.svg new file mode 100644 index 0000000..e81f68a --- /dev/null +++ b/graphics/SquareLogo.svg @@ -0,0 +1,84 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/nuget.config b/nuget.config index 6c66007..f296ae9 100644 --- a/nuget.config +++ b/nuget.config @@ -1,9 +1,14 @@ + + + + + diff --git a/src/Buildvana.Sdk.SourceGenerators/AdditionalAssemblyInfoGenerator.cs b/src/Buildvana.Sdk.SourceGenerators/AdditionalAssemblyInfoGenerator.cs index 0b9f791..6e1f5aa 100644 --- a/src/Buildvana.Sdk.SourceGenerators/AdditionalAssemblyInfoGenerator.cs +++ b/src/Buildvana.Sdk.SourceGenerators/AdditionalAssemblyInfoGenerator.cs @@ -1,11 +1,5 @@ -// --------------------------------------------------------------------------------------- -// Copyright (C) Riccardo De Agostini and contributors. All rights reserved. -// Licensed under the MIT license. +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -// -// Part of this file may be third-party code, distributed under a compatible license. -// See the THIRD-PARTY-NOTICES file in the project root for third-party copyright notices. -// --------------------------------------------------------------------------------------- using System.Text; using Buildvana.Sdk.SourceGenerators.Internal; diff --git a/src/Buildvana.Sdk.SourceGenerators/Internal/AdditionalAssemblyInfoValues.cs b/src/Buildvana.Sdk.SourceGenerators/Internal/AdditionalAssemblyInfoValues.cs index cd3cf67..f034c97 100644 --- a/src/Buildvana.Sdk.SourceGenerators/Internal/AdditionalAssemblyInfoValues.cs +++ b/src/Buildvana.Sdk.SourceGenerators/Internal/AdditionalAssemblyInfoValues.cs @@ -1,11 +1,5 @@ -// --------------------------------------------------------------------------------------- -// Copyright (C) Riccardo De Agostini and contributors. All rights reserved. -// Licensed under the MIT license. +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -// -// Part of this file may be third-party code, distributed under a compatible license. -// See the THIRD-PARTY-NOTICES file in the project root for third-party copyright notices. -// --------------------------------------------------------------------------------------- namespace Buildvana.Sdk.SourceGenerators.Internal; diff --git a/src/Buildvana.Sdk.SourceGenerators/Internal/AnalyzerConfigOptionsProviderExtensions.cs b/src/Buildvana.Sdk.SourceGenerators/Internal/AnalyzerConfigOptionsProviderExtensions.cs index facbe41..df81e58 100644 --- a/src/Buildvana.Sdk.SourceGenerators/Internal/AnalyzerConfigOptionsProviderExtensions.cs +++ b/src/Buildvana.Sdk.SourceGenerators/Internal/AnalyzerConfigOptionsProviderExtensions.cs @@ -1,11 +1,5 @@ -// --------------------------------------------------------------------------------------- -// Copyright (C) Riccardo De Agostini and contributors. All rights reserved. -// Licensed under the MIT license. +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -// -// Part of this file may be third-party code, distributed under a compatible license. -// See the THIRD-PARTY-NOTICES file in the project root for third-party copyright notices. -// --------------------------------------------------------------------------------------- using System; using Microsoft.CodeAnalysis.Diagnostics; diff --git a/src/Buildvana.Sdk.SourceGenerators/Internal/StringBuilderExtensions.cs b/src/Buildvana.Sdk.SourceGenerators/Internal/StringBuilderExtensions.cs index 860a331..7d6797e 100644 --- a/src/Buildvana.Sdk.SourceGenerators/Internal/StringBuilderExtensions.cs +++ b/src/Buildvana.Sdk.SourceGenerators/Internal/StringBuilderExtensions.cs @@ -1,11 +1,5 @@ -// --------------------------------------------------------------------------------------- -// Copyright (C) Riccardo De Agostini and contributors. All rights reserved. -// Licensed under the MIT license. +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -// -// Part of this file may be third-party code, distributed under a compatible license. -// See the THIRD-PARTY-NOTICES file in the project root for third-party copyright notices. -// --------------------------------------------------------------------------------------- using System.Text; using Microsoft.CodeAnalysis; diff --git a/stylecop.json b/stylecop.json index 2ced0f7..f1b4f17 100644 --- a/stylecop.json +++ b/stylecop.json @@ -21,7 +21,7 @@ "namingRules": { "allowCommonHungarianPrefixes": true, - "allowedHungarianPrefixes": [], + "allowedHungarianPrefixes": ["db", "id", "x"], "allowedNamespaceComponents": [], "includeInferredTupleElementNames": false, "tupleElementNameCasing": "PascalCase" @@ -38,13 +38,8 @@ }, "documentationRules": { - "companyName": "Riccardo De Agostini", - "copyrightText": "{decoration}\r\nCopyright (C) {companyName} and contributors. All rights reserved.\nLicensed under the {licenseName} license.\nSee the {licenseFile} file in the project root for full license information.\r\n\r\nPart of this file may be third-party code, distributed under a compatible license.\r\nSee the THIRD-PARTY-NOTICES file in the project root for third-party copyright notices.\r\n{decoration}", - "variables": { - "decoration": "---------------------------------------------------------------------------------------", - "licenseName": "MIT", - "licenseFile": "LICENSE" - }, + "companyName": "Tenacom", + "copyrightText": "Copyright (C) {companyName} and Contributors. Licensed under the MIT license.\r\nSee the LICENSE file in the project root for full license information.", "xmlHeader": false, "documentInterfaces": true, "documentExposedElements": true, diff --git a/version.json b/version.json new file mode 100644 index 0000000..11b4b91 --- /dev/null +++ b/version.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.0-preview", + "assemblyVersion": { + "precision": "major" + }, + "nuGetPackageVersion": { + "semVer": 2.0 + }, + "publicReleaseRefSpec": [ + "^refs/heads/main$", + "^refs/heads/v\\d+\\.\\d+$" + ], + "release": { + "firstUnstableTag": "preview" + }, + "pathFilters": [] +}