diff --git a/.github/scripts/bump-version.ps1 b/.github/scripts/bump-version.ps1 new file mode 100644 index 0000000..f07e762 --- /dev/null +++ b/.github/scripts/bump-version.ps1 @@ -0,0 +1,96 @@ +<# +.SYNOPSIS + Computes (and if needed increments) the release version stored in Directory.Build.props. + +.DESCRIPTION + Directory.Build.props holds RELEASE_MAJOR / RELEASE_MINOR / RELEASE_REVISION for the + most recently released or pending-release version. This script: + + 1. Reads the three parts and computes RELEASE_VERSION. + 2. Checks whether that version has already been released (a git tag named + v or exists; older releases were tagged without the v). + 3. If it was released, increments the requested part (patch by default), + writes the new values back to Directory.Build.props and reports changed=true. + If it was NOT released yet (e.g. a previous release run failed after the bump + was committed), it keeps the version as-is and reports changed=false, so + re-running the release never skips or double-increments a version. + + Results are written to $env:GITHUB_OUTPUT when running in GitHub Actions. + +.EXAMPLE + ./.github/scripts/bump-version.ps1 -Bump patch +#> +[CmdletBinding()] +param( + [ValidateSet('patch', 'minor', 'major')] + [string] $Bump = 'patch', + [string] $PropsPath = 'Directory.Build.props' +) + +$ErrorActionPreference = 'Stop' + +$content = Get-Content -Path $PropsPath -Raw + +function Get-Part([string] $name) { + $m = [regex]::Match($content, "<$name\b[^>]*>\s*(\d+)\s*") + if (-not $m.Success) { throw "Could not find <$name> in $PropsPath" } + return [int] $m.Groups[1].Value +} + +function Set-Part([string] $name, [int] $value) { + $pattern = "(<$name\b[^>]*>)\s*\d+\s*()" + $script:content = [regex]::Replace($script:content, $pattern, "`${1}$value`${2}") +} + +function Test-Released([string] $version) { + foreach ($tag in @("v$version", $version)) { + & git rev-parse -q --verify "refs/tags/$tag" *> $null + if ($LASTEXITCODE -eq 0) { return $true } + } + return $false +} + +$major = Get-Part 'RELEASE_MAJOR' +$minor = Get-Part 'RELEASE_MINOR' +$revision = Get-Part 'RELEASE_REVISION' +$previous = "$major.$minor.$revision" + +if (Test-Released $previous) { + switch ($Bump) { + 'major' { $major++; $minor = 0; $revision = 0 } + 'minor' { $minor++; $revision = 0 } + default { $revision++ } + } + $changed = $true + Write-Host "Version $previous is already released; applying '$Bump' increment." +} +else { + $changed = $false + Write-Host "::notice::Version $previous has not been released yet; releasing it without incrementing." +} + +$version = "$major.$minor.$revision" +if ($changed -and (Test-Released $version)) { + throw "Computed version $version is already tagged. Fix RELEASE_* in $PropsPath before releasing." +} + +if ($changed) { + Set-Part 'RELEASE_MAJOR' $major + Set-Part 'RELEASE_MINOR' $minor + Set-Part 'RELEASE_REVISION' $revision + # Preserve the file's existing line endings / encoding (no BOM). + [System.IO.File]::WriteAllText((Resolve-Path $PropsPath), $content, [System.Text.UTF8Encoding]::new($false)) +} + +Write-Host "RELEASE_VERSION: $previous -> $version (changed=$changed)" + +if ($env:GITHUB_OUTPUT) { + @( + "previous=$previous" + "version=$version" + "major=$major" + "minor=$minor" + "revision=$revision" + "changed=$($changed.ToString().ToLowerInvariant())" + ) | Add-Content -Path $env:GITHUB_OUTPUT +} diff --git a/.github/scripts/verify-package.ps1 b/.github/scripts/verify-package.ps1 new file mode 100644 index 0000000..d975365 --- /dev/null +++ b/.github/scripts/verify-package.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS + Verifies a packed NullFX.CRC NuGet package. + +.DESCRIPTION + Opens the .nupkg produced by `dotnet pack` and checks that: + - the package id and version match what we expect + - README.md is packed and referenced from the nuspec + - the MIT license expression is present + - every target framework in the library project has an assembly in lib/ + - no replace-tokens placeholders (#{...}#) survived + - no test/benchmark assemblies or bin/obj paths were packed + Writes a short summary to $env:GITHUB_STEP_SUMMARY when available. + +.EXAMPLE + ./.github/scripts/verify-package.ps1 -PackageDirectory ./artifacts/package -Version 0.0.0 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $PackageDirectory, + [Parameter(Mandatory)] [string] $Version, + [string] $PackageId = 'NullFX.CRC', + [string] $Project = 'NullFX.CRC/NullFX.CRC.csproj', + # Optional override; by default the list is read from the project. + [string[]] $TargetFrameworks +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$failures = [System.Collections.Generic.List[string]]::new() +function Fail([string] $message) { $failures.Add($message); Write-Host "::error::$message" } + +$nupkgPath = Join-Path $PackageDirectory "$PackageId.$Version.nupkg" +if (-not (Test-Path $nupkgPath)) { + $found = (Get-ChildItem $PackageDirectory -Filter '*.nupkg' | Select-Object -ExpandProperty Name) -join ', ' + throw "Expected package '$nupkgPath' was not found. Found: $found" +} + +# Target frameworks come from the project so this list never drifts. +if ($TargetFrameworks) { + $expectedTfms = $TargetFrameworks +} else { + $tfmRaw = (& dotnet msbuild $Project -getProperty:TargetFrameworks | Out-String).Trim() + $expectedTfms = $tfmRaw.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) +} +if ($expectedTfms.Count -eq 0) { throw "Could not read TargetFrameworks from $Project" } + +$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $nupkgPath)) +try { + $entries = $zip.Entries | ForEach-Object { $_.FullName } + + function Read-Entry([string] $name) { + $entry = $zip.Entries | Where-Object { $_.FullName -eq $name } | Select-Object -First 1 + if (-not $entry) { return $null } + $reader = [System.IO.StreamReader]::new($entry.Open()) + try { return $reader.ReadToEnd() } finally { $reader.Dispose() } + } + + $nuspecName = $entries | Where-Object { $_ -like '*.nuspec' } | Select-Object -First 1 + if (-not $nuspecName) { throw 'No .nuspec found inside the package.' } + [xml] $nuspec = Read-Entry $nuspecName + $meta = $nuspec.package.metadata + + if ($meta.id -ne $PackageId) { Fail "Package id is '$($meta.id)', expected '$PackageId'." } + if ($meta.version -ne $Version) { Fail "Package version is '$($meta.version)', expected '$Version'." } + if ($meta.readme -ne 'README.md') { Fail "nuspec is '$($meta.readme)', expected 'README.md'." } + if ($meta.license.'#text' -ne 'MIT') { Fail "nuspec license is '$($meta.license.'#text')', expected 'MIT'." } + if ($entries -notcontains 'README.md') { Fail 'README.md is not packed at the package root.' } + if ($meta.repository -and -not $meta.repository.commit) { Write-Host '::warning::Repository commit is missing from the nuspec (SourceLink).' } + + foreach ($tfm in $expectedTfms) { + if ($entries -notcontains "lib/$tfm/NullFX.CRC.dll") { Fail "Missing lib/$tfm/NullFX.CRC.dll" } + } + + foreach ($name in @($nuspecName, 'README.md')) { + $text = Read-Entry $name + if ($text -and $text -match '#\{[A-Z_]+\}#') { Fail "Unreplaced token found in $name." } + } + + $unexpected = $entries | Where-Object { + $_ -match '(?i)(^|/)(bin|obj)/' -or $_ -match '(?i)\.Tests\.dll$' -or $_ -match '(?i)Benchmarks' -or $_ -match '(?i)\.snk$' + } + foreach ($u in $unexpected) { Fail "Unexpected file in package: $u" } + + $summary = @( + "### NuGet package: ``$PackageId $($meta.version)``", + '', + "| Check | Result |", + "|---|---|", + "| Version | ``$($meta.version)`` |", + "| Target frameworks | $($expectedTfms -join ', ') |", + "| README | $([bool]($entries -contains 'README.md')) |", + "| License | $($meta.license.'#text') |", + "| Files | $($entries.Count) |", + "| Problems | $($failures.Count) |" + ) -join "`n" + Write-Host $summary + if ($env:GITHUB_STEP_SUMMARY) { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary } +} +finally { + $zip.Dispose() +} + +if ($failures.Count -gt 0) { + throw "Package verification failed with $($failures.Count) problem(s)." +} +Write-Host "Package $nupkgPath verified." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f62344 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,194 @@ +# Pull request / main validation: build every target framework, run unit tests, +# run a short benchmark pass and pack an inspection-only 0.0.0 NuGet package. +# This workflow never changes the version and never publishes anything. +name: CI + +on: + pull_request: + branches: [ main ] + types: [ opened, synchronize, reopened ] + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + # Non-release builds are always versioned 0.0.0; only release.yml sets a real version. + CI_VERSION: 0.0.0 + LIBRARY_PROJECT: NullFX.CRC/NullFX.CRC.csproj + TEST_PROJECT: NullFX.CRC.Tests/NullFX.CRC.Tests.csproj + BENCHMARK_PROJECT: NullFX.CRC.Benchmarks/NullFX.CRC.Benchmarks.csproj + +jobs: + build: + name: Build (all target frameworks) + # Windows is required for the .NET Framework (net40-net48) targets. + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET SDKs + uses: actions/setup-dotnet@v5 + with: + # The .NET 10 SDK builds every target (netcoreapp3.1/net6.0/net7.0 and + # net4x reference packs are restored from NuGet). .NET 8 is installed + # as the fallback runtime. + dotnet-version: | + 8.0.x + 10.0.x + + - name: Show SDKs + run: dotnet --info + + - name: Restore + run: dotnet restore ${{ env.LIBRARY_PROJECT }} + + - name: Build + run: > + dotnet build ${{ env.LIBRARY_PROJECT }} -c Release --no-restore + -p:RELEASE_VERSION=${{ env.CI_VERSION }} + -bl:artifacts/logs/build.binlog + + - name: Verify every target framework was built + shell: pwsh + run: | + $tfms = (dotnet msbuild $env:LIBRARY_PROJECT -getProperty:TargetFrameworks | Out-String).Trim().Split(';') + $missing = $tfms | Where-Object { -not (Test-Path "NullFX.CRC/bin/Release/$_/NullFX.CRC.dll") } + "| Target framework | Built |`n|---|---|" | Add-Content $env:GITHUB_STEP_SUMMARY + foreach ($t in $tfms) { "| $t | $(if ($missing -contains $t) { 'no' } else { 'yes' }) |" | Add-Content $env:GITHUB_STEP_SUMMARY } + if ($missing) { throw "Missing build output for: $($missing -join ', ')" } + + - name: Pack (inspection only, version ${{ env.CI_VERSION }}) + run: > + dotnet pack ${{ env.LIBRARY_PROJECT }} -c Release --no-build + -p:RELEASE_VERSION=${{ env.CI_VERSION }} + -o artifacts/package + + - name: Verify package + shell: pwsh + run: ./.github/scripts/verify-package.ps1 -PackageDirectory artifacts/package -Version $env:CI_VERSION + + - name: Upload package + uses: actions/upload-artifact@v6 + with: + name: nuget-package-pr + path: artifacts/package/ + if-no-files-found: error + retention-days: 14 + + - name: Upload build logs + if: always() + uses: actions/upload-artifact@v6 + with: + name: build-logs + path: artifacts/logs/ + if-no-files-found: ignore + retention-days: 14 + + test: + name: Unit tests (net10.0) + needs: build + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET SDKs + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Restore + run: dotnet restore ${{ env.TEST_PROJECT }} + + - name: Build + run: dotnet build ${{ env.TEST_PROJECT }} -c Release --no-restore -p:RELEASE_VERSION=${{ env.CI_VERSION }} + + - name: Test + run: > + dotnet test ${{ env.TEST_PROJECT }} -c Release --no-build -f net10.0 + --logger "trx;LogFileName=test-results.trx" + --logger "console;verbosity=normal" + --results-directory artifacts/test-results + + - name: Publish test summary + if: always() + shell: pwsh + run: | + $trx = Get-ChildItem artifacts/test-results -Filter *.trx -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $trx) { "### Unit tests`nNo TRX results were produced." | Add-Content $env:GITHUB_STEP_SUMMARY; exit 0 } + [xml] $x = Get-Content $trx.FullName + $c = $x.TestRun.ResultSummary.Counters + $lines = @( + '### Unit tests (net10.0)', '', + '| Outcome | Total | Passed | Failed | Skipped |', '|---|---|---|---|---|', + "| $($x.TestRun.ResultSummary.outcome) | $($c.total) | $($c.passed) | $($c.failed) | $([int]$c.notExecuted) |" + ) + $failed = $x.TestRun.Results.UnitTestResult | Where-Object { $_.outcome -eq 'Failed' } + if ($failed) { $lines += '', '**Failed tests**', ''; $lines += $failed | ForEach-Object { "- ``$($_.testName)``" } } + $lines -join "`n" | Add-Content $env:GITHUB_STEP_SUMMARY + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-results + path: artifacts/test-results/ + if-no-files-found: warn + retention-days: 14 + + benchmark: + name: Benchmarks (net10.0, short run) + needs: build + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET SDKs + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Run benchmarks + # A ShortRun job keeps PR feedback fast (full statistical runs are not + # meaningful on shared CI runners anyway). Results are relative + # NullFX vs. System.Data.HashFunction numbers, not absolute timings. + run: > + dotnet run --project ${{ env.BENCHMARK_PROJECT }} -c Release -f net10.0 + --property:RELEASE_VERSION=${{ env.CI_VERSION }} + -- --job short --exporters json github --artifacts artifacts/benchmarks + + - name: Publish benchmark summary + if: always() + shell: pwsh + run: | + $reports = Get-ChildItem artifacts/benchmarks/results -Filter *-report-github.md -ErrorAction SilentlyContinue + if (-not $reports) { "### Benchmarks`nNo benchmark report was produced." | Add-Content $env:GITHUB_STEP_SUMMARY; exit 0 } + "### Benchmarks (net10.0, ShortRun)`n" | Add-Content $env:GITHUB_STEP_SUMMARY + $reports | ForEach-Object { Get-Content $_.FullName -Raw | Add-Content $env:GITHUB_STEP_SUMMARY } + + - name: Upload benchmark results + if: always() + uses: actions/upload-artifact@v6 + with: + name: benchmark-results + path: artifacts/benchmarks/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/cicd-actions.yml b/.github/workflows/cicd-actions.yml deleted file mode 100644 index 68235cb..0000000 --- a/.github/workflows/cicd-actions.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: build - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - types: [opened, synchronize, reopened] - -jobs: - build: - runs-on: windows-latest - steps: - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 1.11 - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: | - 2.0.x - 3.1.x - 6.0.x - 7.0.x - - name: replace version tokens - uses: cschleiden/replace-tokens@v1.2 - with: - files: '["**/*.csproj"]' - env: - RELEASE_VERSION: ${{ vars.RELEASE_VERSION }} - - name: install dependencies - run: dotnet restore - - name: build source - run: dotnet build -c Release - - name: run tests - run: dotnet test - - name: create nuget packages - run: dotnet pack -c Release -o ./out - - name: push nuget packages to github - run: dotnet nuget push "./out/NullFX.CRC.${{ vars.RELEASE_VERSION }}.nupkg" --skip-duplicate -s "https://nuget.pkg.github.com/nullfx/index.json" -k "${{ secrets.GITHUB_TOKEN }}" - - name: push nuget packages to nuget.org - run: dotnet nuget push "./out/NullFX.CRC.${{ vars.RELEASE_VERSION }}.nupkg" --skip-duplicate -s "https://api.nuget.org/v3/index.json" -k "${{ secrets.NUGET_ORG_API_KEY }}" - - name: Cache SonarCloud packages - uses: actions/cache@v1 - with: - path: ~\sonar\cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - name: Cache SonarCloud scanner - id: cache-sonar-scanner - uses: actions/cache@v1 - with: - path: .\.sonar\scanner - key: ${{ runner.os }}-sonar-scanner - restore-keys: ${{ runner.os }}-sonar-scanner - - name: Install SonarCloud scanner - if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' - shell: powershell - run: | - New-Item -Path .\.sonar\scanner -ItemType Directory - dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner - - name: Build and analyze - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - shell: powershell - run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"nullfx_NullFX.CRC" /o:"nullfx" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.dotnet.excludeTestProjects=true - dotnet build -f net7.0 --no-restore - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5ae30fb --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,59 @@ +# GitHub-native code scanning for C#/.NET (replaces SonarCloud). +# Results appear under Security > Code scanning and as a PR check. +name: CodeQL + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + schedule: + - cron: '30 6 * * 1' # weekly, Monday 06:30 UTC + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + analyze: + name: Analyze (C#) + # Windows so the .NET Framework targets compile exactly as in CI. + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: read + actions: read + security-events: write + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET SDKs + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: csharp + build-mode: manual + queries: security-and-quality + + - name: Build library (all target frameworks) + # Only the shipped library is analyzed; tests and benchmarks are not built. + run: dotnet build NullFX.CRC/NullFX.CRC.csproj -c Release -p:RELEASE_VERSION=0.0.0 -p:UseSharedCompilation=false + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 + with: + category: /language:csharp diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5f6cc3d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,230 @@ +# Release pipeline. Runs after a pull request is merged into main (push to main) +# or on manual dispatch. Order of operations: +# +# 1. Read RELEASE_MAJOR/MINOR/REVISION from Directory.Build.props. +# 2. Increment the version (patch by default) just before the release build. +# Idempotent: if the stored version has no tag yet (an earlier run failed +# after bumping), it is released as-is instead of being incremented again. +# 3. Commit the bump locally, then build all TFMs, pack and verify the package +# with that exact version, then run unit tests and benchmarks. Packing happens +# straight after the build so later test/benchmark rebuilds cannot alter it. +# 4. Push the version commit to main (with [skip ci] so it does not retrigger). +# 5. Publish to GitHub Packages, to nuget.org via Trusted Publishing (only if +# the NUGET_USER secret is set), and create the v tag + GitHub release. +# +# Nothing is published if any build/test/pack/verify/push step fails. +# +# Optional secrets: +# NUGET_USER nuget.org profile name (not email) that owns the Trusted Publishing +# policy for this repo + release.yml. nuget.org publishing is skipped +# without it. No long-lived nuget.org API key is stored anywhere. +# RELEASE_PUSH_TOKEN Token allowed to push to main when branch protection blocks GITHUB_TOKEN. +# RELEASE_VARIABLES_TOKEN Fine-grained PAT with "Variables: read and write" on this repo; when set, +# RELEASE_MAJOR/MINOR/REVISION repository variables are mirrored after release. +name: Release + +on: + push: + branches: [ main ] + workflow_dispatch: + inputs: + bump: + description: Version part to increment + type: choice + options: [ patch, minor, major ] + default: patch + +permissions: + contents: read + +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + LIBRARY_PROJECT: NullFX.CRC/NullFX.CRC.csproj + TEST_PROJECT: NullFX.CRC.Tests/NullFX.CRC.Tests.csproj + BENCHMARK_PROJECT: NullFX.CRC.Benchmarks/NullFX.CRC.Benchmarks.csproj + +jobs: + release: + name: Version, build, pack and publish + if: github.ref == 'refs/heads/main' + runs-on: windows-latest + timeout-minutes: 60 + permissions: + contents: write # push the version commit, create tag + release + packages: write # publish to GitHub Packages + id-token: write # GitHub OIDC token for nuget.org Trusted Publishing + steps: + - uses: actions/checkout@v6 + with: + # Always release the tip of main (not the triggering SHA) so queued runs + # pick up the latest merged state and any earlier version bump. + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_PUSH_TOKEN || github.token }} + + - name: Setup .NET SDKs + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Increment version + id: version + shell: pwsh + run: ./.github/scripts/bump-version.ps1 -Bump '${{ inputs.bump || 'patch' }}' + + - name: Commit version bump (local) + if: steps.version.outputs.changed == 'true' + run: | + git add Directory.Build.props + git commit -m "release: v${{ steps.version.outputs.version }} [skip ci]" + + - name: Restore + run: | + dotnet restore ${{ env.LIBRARY_PROJECT }} + dotnet restore ${{ env.TEST_PROJECT }} + + - name: Build (all target frameworks) + run: > + dotnet build ${{ env.LIBRARY_PROJECT }} -c Release --no-restore + -p:RELEASE_VERSION=${{ steps.version.outputs.version }} + -bl:artifacts/logs/release-build.binlog + + # README.md and the package metadata carry no version placeholders (the version + # comes from MSBuild), so there is nothing to token-replace before packing. + - name: Pack + run: > + dotnet pack ${{ env.LIBRARY_PROJECT }} -c Release --no-build + -p:RELEASE_VERSION=${{ steps.version.outputs.version }} + -o artifacts/package + + - name: Verify package + shell: pwsh + run: ./.github/scripts/verify-package.ps1 -PackageDirectory artifacts/package -Version '${{ steps.version.outputs.version }}' + + - name: Unit tests (net10.0) + run: > + dotnet test ${{ env.TEST_PROJECT }} -c Release --no-restore -f net10.0 + -p:RELEASE_VERSION=${{ steps.version.outputs.version }} + --logger "trx;LogFileName=test-results.trx" + --results-directory artifacts/test-results + + - name: Benchmarks (net10.0, short run) + run: > + dotnet run --project ${{ env.BENCHMARK_PROJECT }} -c Release -f net10.0 + --property:RELEASE_VERSION=${{ steps.version.outputs.version }} + -- --job short --exporters json github --artifacts artifacts/benchmarks + + - name: Upload release artifacts + if: always() + uses: actions/upload-artifact@v6 + with: + name: release-${{ steps.version.outputs.version || 'unknown' }} + path: | + artifacts/package/ + artifacts/test-results/ + artifacts/benchmarks/results/ + artifacts/logs/ + if-no-files-found: warn + retention-days: 90 + + - name: Push version commit to main + if: steps.version.outputs.changed == 'true' + # Fails (and stops the release before anything is published) if main moved + # or branch protection rejects the push; re-running is safe. + run: git push origin HEAD:main + + - name: Check publishing credentials + id: creds + shell: pwsh + env: + NUGET_USER: ${{ secrets.NUGET_USER }} + VARIABLES_TOKEN: ${{ secrets.RELEASE_VARIABLES_TOKEN }} + run: | + "nuget=$(-not [string]::IsNullOrEmpty($env:NUGET_USER))".ToLowerInvariant() | Add-Content $env:GITHUB_OUTPUT + "variables=$(-not [string]::IsNullOrEmpty($env:VARIABLES_TOKEN))".ToLowerInvariant() | Add-Content $env:GITHUB_OUTPUT + + - name: Publish to GitHub Packages + shell: pwsh + env: + GITHUB_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: > + dotnet nuget push "artifacts/package/NullFX.CRC.${{ steps.version.outputs.version }}.nupkg" + --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" + --api-key $env:GITHUB_PACKAGES_TOKEN --skip-duplicate --no-symbols + + # Trusted Publishing: exchange this job's GitHub OIDC token for a short-lived + # (~1 hour) nuget.org API key immediately before pushing. Requires a Trusted + # Publishing policy on nuget.org for owner nullfx, repository NullFX.CRC and + # workflow file release.yml. + - name: NuGet login (OIDC -> temporary API key) + if: steps.creds.outputs.nuget == 'true' + id: nuget-login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Publish to nuget.org + if: steps.creds.outputs.nuget == 'true' + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + # The matching .snupkg symbols package is pushed automatically alongside. + run: > + dotnet nuget push "artifacts/package/NullFX.CRC.${{ steps.version.outputs.version }}.nupkg" + --source "https://api.nuget.org/v3/index.json" + --api-key $env:NUGET_API_KEY --skip-duplicate + + - name: nuget.org publish skipped + if: steps.creds.outputs.nuget != 'true' + run: echo "::warning::NUGET_USER is not configured; skipped publishing to nuget.org (Trusted Publishing)." + + - name: Create GitHub release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + $tag = "v$env:VERSION" + $sha = git rev-parse HEAD + gh release view $tag *> $null + if ($LASTEXITCODE -eq 0) { Write-Host "Release $tag already exists; skipping."; exit 0 } + $notes = "NullFX.CRC $env:VERSION`n`nInstall: ``dotnet add package NullFX.CRC --version $env:VERSION``" + $files = Get-ChildItem artifacts/package -Include *.nupkg, *.snupkg -Recurse | ForEach-Object FullName + gh release create $tag @files --target $sha --title $tag --notes $notes --generate-notes + if ($LASTEXITCODE -ne 0) { throw "gh release create failed" } + + - name: Mirror version to repository variables + if: steps.creds.outputs.variables == 'true' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.RELEASE_VARIABLES_TOKEN }} + run: | + gh variable set RELEASE_MAJOR --body '${{ steps.version.outputs.major }}' --repo '${{ github.repository }}' + gh variable set RELEASE_MINOR --body '${{ steps.version.outputs.minor }}' --repo '${{ github.repository }}' + gh variable set RELEASE_REVISION --body '${{ steps.version.outputs.revision }}' --repo '${{ github.repository }}' + + - name: Release summary + if: always() + shell: pwsh + run: | + @( + "### Release v${{ steps.version.outputs.version }}", + "", + "| | |", "|---|---|", + "| Previous version | ``${{ steps.version.outputs.previous }}`` |", + "| Released version | ``${{ steps.version.outputs.version }}`` |", + "| Version incremented | ${{ steps.version.outputs.changed }} |", + "| nuget.org publish | $(if ('${{ steps.creds.outputs.nuget }}' -eq 'true') { 'enabled (Trusted Publishing)' } else { 'skipped (no NUGET_USER)' }) |" + ) -join "`n" | Add-Content $env:GITHUB_STEP_SUMMARY diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e254536 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,28 @@ + + + + 1 + 1 + 10 + + $(RELEASE_MAJOR).$(RELEASE_MINOR).$(RELEASE_REVISION) + + $(RELEASE_VERSION) + $(RELEASE_VERSION) + $(RELEASE_VERSION).0 + $(RELEASE_VERSION).0 + $(RELEASE_VERSION) + + diff --git a/NullFX.CRC.Benchmarks/NullFX.CRC.Benchmarks.csproj b/NullFX.CRC.Benchmarks/NullFX.CRC.Benchmarks.csproj index e903639..2a2e3f2 100644 --- a/NullFX.CRC.Benchmarks/NullFX.CRC.Benchmarks.csproj +++ b/NullFX.CRC.Benchmarks/NullFX.CRC.Benchmarks.csproj @@ -2,14 +2,13 @@ Exe - netstandard2.0;net47;net48;netcoreapp3.1;net6.0;net7.0 + + net10.0 false - - #{RELEASE_VERSION}# - true - + false + - + diff --git a/NullFX.CRC.Benchmarks/Program.cs b/NullFX.CRC.Benchmarks/Program.cs index ac13ea0..ac0b0b6 100644 --- a/NullFX.CRC.Benchmarks/Program.cs +++ b/NullFX.CRC.Benchmarks/Program.cs @@ -6,7 +6,10 @@ internal static class Program { static void Main(string[] args) { - BenchmarkRunner.Run(); + // Forward command-line arguments so CI can pick the job, exporters and + // artifacts path (e.g. --job short --exporters json github). With no + // arguments this behaves exactly as before. + BenchmarkRunner.Run(args: args); } } } \ No newline at end of file diff --git a/NullFX.CRC.Tests/NullFX.CRC.Tests.csproj b/NullFX.CRC.Tests/NullFX.CRC.Tests.csproj index 002216d..9efa339 100644 --- a/NullFX.CRC.Tests/NullFX.CRC.Tests.csproj +++ b/NullFX.CRC.Tests/NullFX.CRC.Tests.csproj @@ -1,28 +1,22 @@ - net7.0 + + net10.0 false false - - #{RELEASE_VERSION}# - true + true - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - \ No newline at end of file + diff --git a/NullFX.CRC.sln b/NullFX.CRC.sln index c7bea8d..c0f98f8 100644 --- a/NullFX.CRC.sln +++ b/NullFX.CRC.sln @@ -9,7 +9,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NullFX.CRC.Tests", "NullFX. EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{124147BD-9EE1-4469-AF35-834E7B19CB1C}" ProjectSection(SolutionItems) = preProject - .github\workflows\cicd-actions.yml = .github\workflows\cicd-actions.yml + .github\workflows\ci.yml = .github\workflows\ci.yml + .github\workflows\codeql.yml = .github\workflows\codeql.yml + .github\workflows\release.yml = .github\workflows\release.yml + Directory.Build.props = Directory.Build.props README.md = README.md EndProjectSection EndProject diff --git a/NullFX.CRC/NullFX.CRC.csproj b/NullFX.CRC/NullFX.CRC.csproj index 060d3f6..cb98ab3 100644 --- a/NullFX.CRC/NullFX.CRC.csproj +++ b/NullFX.CRC/NullFX.CRC.csproj @@ -1,11 +1,11 @@  - netstandard2.0;net40;net45;net46;net47;net48;netcoreapp3.1;net6.0;net7.0 + netstandard2.0;netcoreapp3.1;net40;net45;net46;net47;net48;net6.0;net7.0;net8.0;net10.0 NullFX.CRC NullFX CRC is a small set of CRC utilities (crc8, crc16, and crc32) written in C# and released under the MIT License 2023 Steve Whitley - true + false Steve Whitley NullFX Software CRC8 CRC16 CRC32 @@ -20,13 +20,15 @@ snupkg MIT Release;Debug - True - - #{RELEASE_VERSION}# - #{RELEASE_VERSION}#.0 - #{RELEASE_VERSION}#.0 - #{RELEASE_VERSION}# - #{RELEASE_VERSION}# + + true + + True + NullFX.CRC.snk @@ -38,9 +40,7 @@ TRACE;DEBUG;NET;NETCOREAPP TRACE;DEBUG;NET;NETCOREAPP - - - + diff --git a/NullFX.CRC/NullFX.CRC.snk b/NullFX.CRC/NullFX.CRC.snk new file mode 100644 index 0000000..e85a14b Binary files /dev/null and b/NullFX.CRC/NullFX.CRC.snk differ diff --git a/README.md b/README.md index 29ab2f8..e23e171 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# NullFX CRC [![build](https://github.com/nullfx/NullFX.CRC/actions/workflows/cicd-actions.yml/badge.svg)](https://github.com/nullfx/NullFX.CRC/actions/workflows/cicd-actions.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nullfx_NullFX.CRC&metric=alert_status)](https://sonarcloud.io/dashboard?id=nullfx_NullFX.CRC) +# NullFX CRC [![CI](https://github.com/nullfx/NullFX.CRC/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/nullfx/NullFX.CRC/actions/workflows/ci.yml) [![CodeQL](https://github.com/nullfx/NullFX.CRC/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/nullfx/NullFX.CRC/actions/workflows/codeql.yml) [![NuGet](https://img.shields.io/nuget/v/NullFX.CRC.svg)](https://www.nuget.org/packages/NullFX.CRC) NullFX CRC is a small set of CRC utilities written in native C# released under the MIT License @@ -11,9 +11,13 @@ NullFX CRC is a small set of CRC utilities written in native C# released under t ## Install ```sh -dotnet add PROJECT package NullFX.CRC --version 1.1.10 +dotnet add package NullFX.CRC ``` +Run this from the folder that contains your project file (or add the project path after `dotnet add`). The latest released version is installed; the NuGet badge above shows the current version. + +Supported target frameworks: `netstandard2.0`, `netcoreapp3.1`, `net40`, `net45`, `net46`, `net47`, `net48`, `net6.0`, `net7.0`, `net8.0` and `net10.0`. + ## Examples: Each CRC library uses a common `ComputeChecksum` format. It accepts a byte array which can be obtained by converting text / numbers / structures etc into an array, then passing the byte array into `ComputeChecksum` for it's CRC.