diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ad03c83 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,277 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + MAJOR_MINOR: '1.14' + +permissions: + contents: write + pull-requests: write + security-events: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build -c Release --no-restore 2>&1 | tee build.log + + - name: Check warnings + run: | + WARNING_COUNT=$(grep -c " warning " build.log || true) + echo "Build warnings: $WARNING_COUNT" + if [ "$WARNING_COUNT" -gt 15 ]; then + echo "::error::Build has $WARNING_COUNT warnings (threshold: 15)" + exit 1 + fi + + - name: Test with coverage + run: dotnet test -c Release --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + directory: ./coverage + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Compute version + id: version + run: | + LATEST_TAG=$(git tag -l "${MAJOR_MINOR}.*" --sort=-v:refname \ + | grep -E "^${MAJOR_MINOR}\.[0-9]+$" \ + | head -1) + + if [ -z "$LATEST_TAG" ]; then + PATCH=0 + else + PATCH=$(echo "$LATEST_TAG" | sed "s/${MAJOR_MINOR}\.\([0-9]*\)/\1/") + PATCH=$((PATCH + 1)) + fi + + VERSION="${MAJOR_MINOR}.${PATCH}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "previous_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" + echo "Computed version: $VERSION (previous: $LATEST_TAG)" + + - name: Compute pre-release version + id: preversion + if: github.ref != 'refs/heads/master' + run: | + VERSION="${{ steps.version.outputs.version }}" + PRE_BASE="${VERSION}-pre" + + LATEST_PRE=$(git tag -l "${PRE_BASE}.*" --sort=-v:refname | head -1) + + if [ -z "$LATEST_PRE" ]; then + COUNTER=1 + else + COUNTER=$(echo "$LATEST_PRE" | sed "s/.*-pre\.\([0-9]*\)/\1/") + COUNTER=$((COUNTER + 1)) + fi + + PRE_VERSION="${PRE_BASE}.${COUNTER}" + echo "version=$PRE_VERSION" >> "$GITHUB_OUTPUT" + echo "Computed pre-release version: $PRE_VERSION" + + - name: Pack (stable — push to master) + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + run: | + dotnet pack Tharga.Test.Toolkit/Tharga.Test.Toolkit.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }} + + - name: Pack (pre-release — pull request) + if: github.event_name == 'pull_request' + run: | + dotnet pack Tharga.Test.Toolkit/Tharga.Test.Toolkit.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: ./artifacts/ + + security: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: csharp + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Build for CodeQL + run: dotnet build -c Release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + + release: + needs: [build, security] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + environment: release + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: ./artifacts/ + + - name: Compute version + id: version + run: | + LATEST_TAG=$(git tag -l "${MAJOR_MINOR}.*" --sort=-v:refname \ + | grep -E "^${MAJOR_MINOR}\.[0-9]+$" \ + | head -1) + + if [ -z "$LATEST_TAG" ]; then + PATCH=0 + else + PATCH=$(echo "$LATEST_TAG" | sed "s/${MAJOR_MINOR}\.\([0-9]*\)/\1/") + PATCH=$((PATCH + 1)) + fi + + VERSION="${MAJOR_MINOR}.${PATCH}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "previous_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" + + - name: Push to NuGet + run: | + for pkg in ./artifacts/*.nupkg; do + echo "Pushing $pkg..." + dotnet nuget push "$pkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate || true + done + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NOTES_FLAG="" + if [ -n "${{ steps.version.outputs.previous_tag }}" ]; then + NOTES_FLAG="--notes-start-tag ${{ steps.version.outputs.previous_tag }}" + fi + gh release create "${{ steps.version.outputs.version }}" \ + --title "v${{ steps.version.outputs.version }}" \ + --generate-notes \ + $NOTES_FLAG \ + ./artifacts/*.nupkg + + - name: Comment released version on merged PR + if: always() + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(echo "${{ github.event.head_commit.message }}" | grep -oP 'Merge pull request #\K\d+' | head -1) + if [ -n "$PR_NUMBER" ]; then + gh pr comment "$PR_NUMBER" --body "Released as **v${{ steps.version.outputs.version }}** — https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.version }}" + else + echo "No PR number found in commit message — skipping PR comment." + fi + + prerelease: + needs: [build, security] + runs-on: ubuntu-latest + if: github.ref != 'refs/heads/master' && github.event_name == 'pull_request' + environment: prerelease + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: ./artifacts/ + + - name: Compute pre-release version + id: version + run: | + LATEST_TAG=$(git tag -l "${MAJOR_MINOR}.*" --sort=-v:refname \ + | grep -E "^${MAJOR_MINOR}\.[0-9]+$" \ + | head -1) + + if [ -z "$LATEST_TAG" ]; then + PATCH=0 + else + PATCH=$(echo "$LATEST_TAG" | sed "s/${MAJOR_MINOR}\.\([0-9]*\)/\1/") + PATCH=$((PATCH + 1)) + fi + + PRE_BASE="${MAJOR_MINOR}.${PATCH}-pre" + LATEST_PRE=$(git tag -l "${PRE_BASE}.*" --sort=-v:refname | head -1) + + if [ -z "$LATEST_PRE" ]; then + COUNTER=1 + else + COUNTER=$(echo "$LATEST_PRE" | sed "s/.*-pre\.\([0-9]*\)/\1/") + COUNTER=$((COUNTER + 1)) + fi + + VERSION="${PRE_BASE}.${COUNTER}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Push to NuGet + run: | + for pkg in ./artifacts/*.nupkg; do + echo "Pushing $pkg..." + dotnet nuget push "$pkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate || true + done + + - name: Create GitHub Pre-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ steps.version.outputs.version }}" \ + --title "v${{ steps.version.outputs.version }} (pre-release)" \ + --notes "Pre-release from \`${{ github.head_ref }}\` branch." \ + --prerelease \ + ./artifacts/*.nupkg diff --git a/Tharga.Test.sln b/Tharga.Test.sln index a3ca656..ecd4613 100644 --- a/Tharga.Test.sln +++ b/Tharga.Test.sln @@ -5,8 +5,6 @@ VisualStudioVersion = 18.2.11415.280 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{56A486D0-5EF5-47EF-8796-08C8F0577A5D}" ProjectSection(SolutionItems) = preProject - azure-pipelines.yml = azure-pipelines.yml - buildnumber.yml = buildnumber.yml LICENSE = LICENSE README.md = README.md EndProjectSection diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 0fd1ad9..0000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,103 +0,0 @@ -trigger: -- master - -pool: - vmImage: 'windows-latest' - -variables: - buildPlatform: 'Any CPU' - buildConfiguration: 'Release' - BQC.ForceNewBaseline: true - majorMinor: '1.14' - -stages: -- stage: Build - displayName: Build - jobs: - - job: Build - displayName: Build and Test - - steps: - - checkout: self - - - template: buildnumber.yml - parameters: - majorMinor: ${{ variables.majorMinor }} - - - task: NuGetToolInstaller@1 - - - task: DotNetCoreCLI@2 - displayName: 'Restore nuget packages' - inputs: - command: 'restore' - projects: '**/*.csproj' - feedsToUse: 'select' - - - task: DotNetCoreCLI@2 - displayName: 'Build' - inputs: - command: 'build' - projects: '**/*.csproj' - arguments: '-c Release --no-restore /p:Version=$(Build.BuildNumber)' - versioningScheme: 'byBuildNumber' - - - task: DotNetCoreCLI@2 - displayName: 'Test' - inputs: - command: 'test' - projects: '**/*.Tests.csproj' - arguments: '--collect "Code coverage" --configuration $(buildConfiguration) --filter "Category!=Database"' - - # - task: BuildQualityChecks@8 - # displayName: 'Build Quality Checks' - # inputs: - # checkWarnings: true - # warningFailOption: 'build' - # allowWarningVariance: true - # warningVariance: '5' - # checkCoverage: true - # coverageFailOption: 'build' - # coverageType: 'blocks' - # allowCoverageVariance: true - # coverageVariance: '5' - - - task: DotNetCoreCLI@2 - displayName: 'Pack Tharga.Test.Toolkit' - inputs: - command: 'pack' - packagesToPack: '**/Tharga.Test.Toolkit.csproj' - versioningScheme: 'byBuildNumber' - - - task: PublishBuildArtifacts@1 - displayName: 'Publish artifacts' - inputs: - PathtoPublish: '$(Build.ArtifactStagingDirectory)' - ArtifactName: 'drop' - -- stage: Release - displayName: Release - dependsOn: Build - condition: and(succeeded(), eq(variables['Build.SourceBranchName'], 'master')) - jobs: - - job: Release - displayName: Release - - steps: - - download: current - artifact: drop - - checkout: self - persistCredentials: true - - - task: NuGetCommand@2 - displayName: 'Push .nupkg to NuGet.org (with symbols)' - inputs: - command: 'push' - packagesToPush: '$(Pipeline.Workspace)/**/*.symbols.nupkg' - nuGetFeedType: 'external' - publishFeedCredentials: 'Nuget.org' - - - script: | - git tag $(Build.BuildNumber) - git push origin $(Build.BuildNumber) - workingDirectory: $(Build.SourcesDirectory) - displayName: Tag diff --git a/buildnumber.yml b/buildnumber.yml deleted file mode 100644 index ea37132..0000000 --- a/buildnumber.yml +++ /dev/null @@ -1,102 +0,0 @@ -# buildnumber.yml (steps template) -parameters: -- name: majorMinor - type: string - -- name: assignBuildVersionScript - type: string - default: | - $branchMaster = "refs/heads/master" - $majorMinor = $env:MAJOR_MINOR - - if ([string]::IsNullOrWhiteSpace($majorMinor)) { - throw "MAJOR_MINOR is empty. Ensure it is passed via env: MAJOR_MINOR." - } - - $orgUrl = $env:SYSTEM_COLLECTIONURI - $project = $env:SYSTEM_TEAMPROJECT - - $defId = $env:DEF_ID - if ([string]::IsNullOrWhiteSpace($defId) -or $defId -eq "-1") { - throw "DEF_ID is empty or -1 (defId='$defId')." - } - - $headers = @{ - Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" - } - - $uri = "$orgUrl$project/_apis/build/builds?definitions=$defId&branchName=$branchMaster&`$top=50&queryOrder=finishTimeDescending&api-version=7.1" - $resp = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get - - $releasePattern = "^$([regex]::Escape($majorMinor))\.(\d+)$" - $anyPattern = "^$([regex]::Escape($majorMinor))\.(\d+)(?:-pre\.\d+)?$" - - $picked = $null - if ($resp -and $resp.value -and $resp.count -ge 1) { - $latestRelease = $resp.value | Where-Object { $_.buildNumber -match $releasePattern } | Select-Object -First 1 - $latestAny = $resp.value | Where-Object { $_.buildNumber -match $anyPattern } | Select-Object -First 1 - - $picked = $latestRelease - if (-not $picked) { $picked = $latestAny } - } - - if (-not $picked) { - $patch = -1 # so nextPatch becomes 0 for a brand-new majorMinor/pipeline - } - else { - $latestBuildNumber = $picked.buildNumber - if ($latestBuildNumber -match $releasePattern) { $patch = $Matches[1] } - elseif ($latestBuildNumber -match $anyPattern) { $patch = $Matches[1] } - else { throw "Internal mismatch: '$latestBuildNumber' should have matched but did not." } - } - - $nextPatch = ([int]$patch) + 1 - - if ($env:BUILD_SOURCEBRANCH -eq $branchMaster) { - $newBuildNumber = "$majorMinor.$nextPatch" - } - else { - $preBase = "$majorMinor.$nextPatch-pre" - $prePattern = "^$([regex]::Escape($preBase))\.(\d+)$" - - $allUri = "$orgUrl$project/_apis/build/builds?definitions=$defId&`$top=100&queryOrder=finishTimeDescending&api-version=7.1" - $allResp = Invoke-RestMethod -Uri $allUri -Headers $headers -Method Get - - $maxCounter = $allResp.value | - Where-Object { $_.buildNumber -match $prePattern } | - ForEach-Object { [int]$Matches[1] } | - Measure-Object -Maximum | - Select-Object -ExpandProperty Maximum - - $preCounter = if ($maxCounter) { $maxCounter + 1 } else { 1 } - $newBuildNumber = "$preBase.$preCounter" - } - - Write-Host "##vso[build.updatebuildnumber]$newBuildNumber" - -steps: -# Windows -- task: PowerShell@2 - displayName: "Assign build version" - condition: eq( variables['Agent.OS'], 'Windows_NT' ) - inputs: - targetType: inline - pwsh: false - script: ${{ parameters.assignBuildVersionScript }} - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - DEF_ID: $(System.DefinitionId) - MAJOR_MINOR: ${{ parameters.majorMinor }} - -# Linux/macOS -- task: PowerShell@2 - displayName: "Assign build version" - condition: ne( variables['Agent.OS'], 'Windows_NT' ) - inputs: - targetType: inline - pwsh: true - script: ${{ parameters.assignBuildVersionScript }} - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - DEF_ID: $(System.DefinitionId) - MAJOR_MINOR: ${{ parameters.majorMinor }}