<# .SYNOPSIS Release script for MaksIT.Core NuGet package and GitHub release. .DESCRIPTION This script automates the release process for MaksIT.Core library. The script is IDEMPOTENT - you can safely re-run it if any step fails. It will skip already-completed steps (NuGet and GitHub) and only create what's missing. Features: - Validates environment and prerequisites - Checks if version already exists on NuGet.org (skips if released) - Checks if GitHub release exists (skips if released) - Scans for vulnerable packages (security check) - Builds and tests the project (Windows + Linux via Docker) - Collects code coverage with Coverlet (threshold enforcement optional) - Generates test result artifacts (TRX format) and coverage reports - Displays test results with pass/fail counts and coverage percentage - Publishes to NuGet.org - Creates a GitHub release with changelog and package assets - Shows timing summary for all steps .REQUIREMENTS Environment Variables: - NUGET_MAKS_IT : NuGet.org API key for publishing packages - GITHUB_MAKS_IT_COM : GitHub Personal Access Token (needs 'repo' scope) Tools (Required): - dotnet CLI : For building, testing, and packing - git : For version control operations - gh (GitHub CLI) : For creating GitHub releases - docker : For cross-platform Linux testing .WORKFLOW 1. VALIDATION PHASE - Check required environment variables (NuGet key, GitHub token) - Check required tools are installed (dotnet, git, gh, docker) - Verify no uncommitted changes in working directory - Authenticate GitHub CLI 2. VERSION & RELEASE CHECK PHASE (Idempotent) - Read latest version from CHANGELOG.md - Find commit with matching version tag - Validate tag is on configured release branch (from scriptsettings.json) - Check if already released on NuGet.org (mark for skip if yes) - Check if GitHub release exists (mark for skip if yes) - Read target framework from MaksIT.Core.csproj - Extract release notes from CHANGELOG.md for current version 3. SECURITY SCAN - Check for vulnerable packages (dotnet list package --vulnerable) - Fail or warn based on $failOnVulnerabilities setting 4. BUILD & TEST PHASE - Clean previous builds (delete bin/obj folders) - Restore NuGet packages - Windows: Build main project -> Build test project -> Run tests with coverage - Analyze code coverage (fail if below threshold when configured) - Linux (Docker): Build main project -> Build test project -> Run tests (TRX report) - Rebuild for Windows (Docker may overwrite bin/obj) - Create NuGet package (.nupkg) and symbols (.snupkg) - All steps are timed for performance tracking 5. CONFIRMATION PHASE - Display release summary - Prompt user for confirmation before proceeding 6. NUGET RELEASE PHASE (Idempotent) - Skip if version already exists on NuGet.org - Otherwise, push package to NuGet.org 7. GITHUB RELEASE PHASE (Idempotent) - Skip if release already exists - Push tag to remote if not already there - Create GitHub release with: * Release notes from CHANGELOG.md * .nupkg and .snupkg as downloadable assets 8. COMPLETION PHASE - Display timing summary for all steps - Display test results summary - Display success summary with links - Open NuGet and GitHub release pages in browser - TODO: Email notification (template provided) - TODO: Package signing (template provided) .USAGE Before running: 1. Ensure Docker Desktop is running (for Linux tests) 2. Update version in MaksIT.Core.csproj 3. Run .\Generate-Changelog.ps1 to update CHANGELOG.md and LICENSE.md 4. Review and commit all changes 5. Create version tag: git tag v1.x.x 6. Run: .\Release-NuGetPackage.ps1 Note: The script finds the commit with the tag matching CHANGELOG.md version. You can run it from any branch/commit - it releases the tagged commit. Re-run release (idempotent - skips NuGet/GitHub if already released): .\Release-NuGetPackage.ps1 Generate changelog and update LICENSE year: .\Generate-Changelog.ps1 .CONFIGURATION All settings are stored in scriptsettings.json: - qualityGates: Coverage threshold, vulnerability checks - packageSigning: Code signing certificate configuration - emailNotification: SMTP settings for release notifications .NOTES Author: Maksym Sadovnychyy (MAKS-IT) Repository: https://github.com/MAKS-IT-COM/maksit-core #> # No parameters - behavior is controlled by current branch (configured in scriptsettings.json): # - dev branch -> Local build only (no tag required, uncommitted changes allowed) # - release branch -> Full release to GitHub (tag required, clean working directory) # Get the directory of the current script (for loading settings and relative paths) $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path #region Import Modules # Import TestRunner module $utilsDir = Split-Path $scriptDir -Parent $testRunnerModulePath = Join-Path $utilsDir "TestRunner.psm1" if (-not (Test-Path $testRunnerModulePath)) { Write-Error "TestRunner module not found at: $testRunnerModulePath" exit 1 } Import-Module $testRunnerModulePath -Force # Import ScriptConfig module $scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" if (-not (Test-Path $scriptConfigModulePath)) { Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" exit 1 } Import-Module $scriptConfigModulePath -Force # Import Logging module $loggingModulePath = Join-Path $utilsDir "Logging.psm1" if (-not (Test-Path $loggingModulePath)) { Write-Error "Logging module not found at: $loggingModulePath" exit 1 } Import-Module $loggingModulePath -Force # Import GitTools module $gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1" if (-not (Test-Path $gitToolsModulePath)) { Write-Error "GitTools module not found at: $gitToolsModulePath" exit 1 } Import-Module $gitToolsModulePath -Force #endregion #region Load Settings $settings = Get-ScriptSettings -ScriptDir $scriptDir #endregion #region Configuration # GitHub configuration $githubReleseEnabled = $settings.github.enabled $githubTokenEnvVar = $settings.github.githubToken $githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar) # NuGet configuration $nugetReleseEnabled = $settings.nuget.enabled $nugetApiKeyEnvVar = $settings.nuget.nugetApiKey $nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar) $nugetSource = if ($settings.nuget.source) { $settings.nuget.source } else { "https://api.nuget.org/v3/index.json" } # Paths from settings (resolve relative to script directory) $csprojPaths = @() $rawCsprojPaths = @() if ($settings.paths.csprojPaths) { if ($settings.paths.csprojPaths -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPaths -is [string])) { $rawCsprojPaths += $settings.paths.csprojPaths } else { $rawCsprojPaths += $settings.paths.csprojPaths } } else { Write-Error "No csproj path configured. Set 'paths.csprojPaths' (preferred) or 'paths.csprojPath' in scriptsettings.json." exit 1 } foreach ($path in $rawCsprojPaths) { if ([string]::IsNullOrWhiteSpace($path)) { continue } $resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path)) $csprojPaths += $resolvedPath } if ($csprojPaths.Count -eq 0) { Write-Error "No valid csproj paths configured in scriptsettings.json." exit 1 } $testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testResultsDir)) $stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir)) $releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir)) $changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath)) $testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject)) # Release naming patterns $zipNamePattern = $settings.release.zipNamePattern $releaseTitlePattern = $settings.release.releaseTitlePattern # Branch configuration $releaseBranch = $settings.branches.release $devBranch = $settings.branches.dev #endregion #region Helpers # Helper: extract a csproj property (first match) function Get-CsprojPropertyValue { param( [Parameter(Mandatory=$true)][xml]$csproj, [Parameter(Mandatory=$true)][string]$propertyName ) $propNode = $csproj.Project.PropertyGroup | Where-Object { $_.$propertyName } | Select-Object -First 1 if ($propNode) { return $propNode.$propertyName } return $null } # Helper: resolve output assembly name for published exe function Resolve-ProjectExeName { param( [Parameter(Mandatory=$true)][string]$projPath ) [xml]$csproj = Get-Content $projPath $assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName" if ($assemblyName) { return $assemblyName } return [System.IO.Path]::GetFileNameWithoutExtension($projPath) } # Helper: check for uncommitted changes function Assert-WorkingTreeClean { param( [Parameter(Mandatory = $true)] [bool]$IsReleaseBranch ) $gitStatus = Get-GitStatusShort if ($gitStatus) { if ($IsReleaseBranch) { Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing." Write-Log -Level "WARN" -Message "Uncommitted files:" $gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } exit 1 } else { Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)." } } else { Write-Log -Level "OK" -Message " Working directory is clean." } } # Helper: read versions from csproj files function Get-CsprojVersions { param( [Parameter(Mandatory = $true)] [string[]]$CsprojPaths ) Write-Log -Level "INFO" -Message "Reading version(s) from csproj(s)..." $projectVersions = @{} foreach ($projPath in $CsprojPaths) { if (-not (Test-Path $projPath -PathType Leaf)) { Write-Error "Csproj file not found at: $projPath" exit 1 } if ([System.IO.Path]::GetExtension($projPath) -ne ".csproj") { Write-Error "Configured path is not a .csproj file: $projPath" exit 1 } [xml]$csproj = Get-Content $projPath $version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version" if (-not $version) { Write-Error "Version not found in $projPath" exit 1 } $projectVersions[$projPath] = $version Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projPath)): $version" } return $projectVersions } #endregion #region Validate CLI Dependencies Assert-Command dotnet Assert-Command git Assert-Command docker # gh command check deferred until after branch detection (only needed on release branch) #endregion #region Main Write-Log -Level "STEP" -Message "==================================================" Write-Log -Level "STEP" -Message "RELEASE BUILD" Write-Log -Level "STEP" -Message "==================================================" #region Preflight $isDevBranch = $false $isReleaseBranch = $false # 1. Detect current branch and determine release mode $currentBranch = Get-CurrentBranch $isDevBranch = $currentBranch -eq $devBranch $isReleaseBranch = $currentBranch -eq $releaseBranch if (-not $isDevBranch -and -not $isReleaseBranch) { Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch" exit 1 } # 2. Check for uncommitted changes (required on release branch, allowed on dev) Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch # 3. Get version from csproj (source of truth) $projectVersions = Get-CsprojVersions -CsprojPaths $csprojPaths # Use the first project's version as the release version $version = $projectVersions[$csprojPaths[0]] # 4. Handle tag based on branch if ($isReleaseBranch) { # Release branch: tag is required and must match version $tag = Get-CurrentCommitTag -Version $version if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)." exit 1 } $tagVersion = $Matches[1] if ($tagVersion -ne $version) { Write-Error "Tag version ($tagVersion) does not match csproj version ($version)." Write-Log -Level "WARN" -Message " Either update the tag or the csproj version." exit 1 } Write-Log -Level "OK" -Message " Tag found: $tag (matches csproj)" } else { # Dev branch: no tag required, use version from csproj $tag = "v$version" Write-Log -Level "INFO" -Message " Using version from csproj (no tag required on dev)." } # 5. Verify CHANGELOG.md has matching version entry Write-Log -Level "INFO" -Message "Verifying CHANGELOG.md..." if (-not (Test-Path $changelogPath)) { Write-Error "CHANGELOG.md not found at: $changelogPath" exit 1 } $changelog = Get-Content $changelogPath -Raw if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') { Write-Error "No version entry found in CHANGELOG.md" exit 1 } $changelogVersion = $Matches[1] if ($changelogVersion -ne $version) { Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)." Write-Log -Level "WARN" -Message " Update CHANGELOG.md or the csproj version." exit 1 } Write-Log -Level "OK" -Message " CHANGELOG.md version matches: v$changelogVersion" Write-Log -Level "OK" -Message "All pre-flight checks passed!" #endregion #region Test Write-Log -Level "STEP" -Message "Running tests..." # Run tests using TestRunner module $testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -ResultsDirectory $testResultsDir -Silent if (-not $testResult.Success) { Write-Error "Tests failed. Release aborted." Write-Log -Level "ERROR" -Message " Error: $($testResult.Error)" exit 1 } Write-Log -Level "OK" -Message " All tests passed!" Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%" Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%" Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%" #endregion #region Build And Publish # 7. Prepare staging directory Write-Log -Level "STEP" -Message "Preparing staging directory..." if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force } New-Item -ItemType Directory -Path $stagingDir | Out-Null $binDir = Join-Path $stagingDir "bin" # 8. Publish the project to staging/bin Write-Log -Level "STEP" -Message "Publishing projects to bin folder..." $publishSuccess = $true $publishedProjects = @() foreach ($projPath in $csprojPaths) { $projName = [System.IO.Path]::GetFileNameWithoutExtension($projPath) $projBinDir = Join-Path $binDir $projName dotnet publish $projPath -c Release -o $projBinDir if ($LASTEXITCODE -ne 0) { Write-Error "dotnet publish failed for $projName." $publishSuccess = $false } else { $exeBaseName = Resolve-ProjectExeName -projPath $projPath $publishedProjects += [PSCustomObject]@{ ProjPath = $projPath ProjName = $projName BinDir = $projBinDir ExeBaseName = $exeBaseName } Write-Log -Level "OK" -Message " Published $projName successfully to: $projBinDir" } } if (-not $publishSuccess) { exit 1 } # 12. Prepare release directory if (!(Test-Path $releaseDir)) { New-Item -ItemType Directory -Path $releaseDir | Out-Null } # 13. Create zip file $zipName = $zipNamePattern $zipName = $zipName -replace '\{version\}', $version $zipPath = Join-Path $releaseDir $zipName if (Test-Path $zipPath) { Remove-Item $zipPath -Force } Write-Log -Level "STEP" -Message "Creating archive $zipName..." Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -Force if (-not (Test-Path $zipPath)) { Write-Error "Failed to create archive $zipPath" exit 1 } Write-Log -Level "OK" -Message " Archive created: $zipPath" # 14. Pack NuGet package and resolve produced .nupkg file $packageProjectPath = $csprojPaths[0] Write-Log -Level "STEP" -Message "Packing NuGet package..." dotnet pack $packageProjectPath -c Release -o $releaseDir --nologo if ($LASTEXITCODE -ne 0) { Write-Error "dotnet pack failed for $packageProjectPath." exit 1 } $packageFile = Get-ChildItem -Path $releaseDir -Filter "*.nupkg" | Where-Object { $_.Name -like "*$version*.nupkg" -and $_.Name -notlike "*.symbols.nupkg" -and $_.Name -notlike "*.snupkg" } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $packageFile) { Write-Error "Could not locate generated NuGet package for version $version in: $releaseDir" exit 1 } Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)" # 15. Extract release notes from CHANGELOG.md Write-Log -Level "STEP" -Message "Extracting release notes..." $pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)" $match = [regex]::Match($changelog, $pattern) if (-not $match.Success) { Write-Error "Changelog entry for version $version not found." exit 1 } $releaseNotes = $match.Value.Trim() Write-Log -Level "OK" -Message " Release notes extracted." # 16. Get repository info $remoteUrl = git config --get remote.origin.url if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) { Write-Error "Could not determine git remote origin URL." exit 1 } if ($remoteUrl -match "[:/](?[^/]+)/(?[^/.]+)(\.git)?$") { $owner = $matches['owner'] $repoName = $matches['repo'] $repo = "$owner/$repoName" } else { Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl" exit 1 } $releaseName = $releaseTitlePattern -replace '\{version\}', $version Write-Log -Level "STEP" -Message "Release Summary:" Write-Log -Level "INFO" -Message " Repository: $repo" Write-Log -Level "INFO" -Message " Tag: $tag" Write-Log -Level "INFO" -Message " Title: $releaseName" # 17. Check if tag is pushed to remote (skip on dev branch) if (-not $isDevBranch) { Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..." $remoteTagExists = Test-RemoteTagExists -Tag $tag -Remote "origin" if (-not $remoteTagExists) { Write-Log -Level "WARN" -Message " Tag $tag not found on remote. Pushing..." Push-TagToRemote -Tag $tag -Remote "origin" } else { Write-Log -Level "OK" -Message " Tag exists on remote." } # Release to GitHub if ($githubReleseEnabled) { Write-Log -Level "STEP" -Message " Release branch ($releaseBranch) - will publish to GitHub." Assert-Command gh $ghApiAuthArgs = @( "-H", "Authorization: token $githubToken" ) # 6. Check GitHub authentication Write-Log -Level "INFO" -Message "Checking GitHub authentication..." if (-not $githubToken) { Write-Error "GitHub token is not set. Set '$githubTokenEnvVar' and rerun." exit 1 } $authArgs = @("api", "user") + $ghApiAuthArgs $authTest = & gh @authArgs 2>$null if ($LASTEXITCODE -ne 0 -or -not $authTest) { Write-Error "GitHub CLI authentication failed. GitHub token may be invalid or missing repo scope." exit 1 } Write-Log -Level "OK" -Message " GitHub CLI authenticated." # 18. Create or update GitHub release Write-Log -Level "STEP" -Message "Creating GitHub release..." # gh release subcommands do not support custom auth headers. # Scope GH_TOKEN to this block so commands authenticate with the configured token. $previousGhToken = $env:GH_TOKEN $env:GH_TOKEN = $githubToken try { # Check if release already exists $releaseViewArgs = @( "release", "view", $tag, "--repo", $repo ) & gh @releaseViewArgs 2>$null if ($LASTEXITCODE -eq 0) { Write-Log -Level "WARN" -Message " Release $tag already exists. Deleting..." $releaseDeleteArgs = @("release", "delete", $tag, "--repo", $repo, "--yes") & gh @releaseDeleteArgs if ($LASTEXITCODE -ne 0) { Write-Error "Failed to delete existing release $tag." exit 1 } } # Create release using the existing tag # Write release notes to a temp file to avoid shell interpretation issues with special characters $notesFilePath = Join-Path $releaseDir "release-notes-temp.md" [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false)) $createReleaseArgs = @( "release", "create", $tag, $zipPath "--repo", $repo "--title", $releaseName "--notes-file", $notesFilePath ) & gh @createReleaseArgs $ghExitCode = $LASTEXITCODE # Cleanup temp notes file if (Test-Path $notesFilePath) { Remove-Item $notesFilePath -Force } if ($ghExitCode -ne 0) { Write-Error "Failed to create GitHub release for tag $tag." exit 1 } } finally { if ($null -ne $previousGhToken) { $env:GH_TOKEN = $previousGhToken } else { Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue } } Write-Log -Level "OK" -Message " GitHub release created successfully." } else { Write-Log -Level "WARN" -Message "Skipping GitHub release (disabled)." } # Release to NuGet if ($nugetReleseEnabled) { Write-Log -Level "STEP" -Message "Pushing to NuGet.org..." dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate if ($LASTEXITCODE -ne 0) { Write-Error "Failed to push the package to NuGet." exit 1 } Write-Log -Level "OK" -Message " NuGet push completed." } else { Write-Log -Level "WARN" -Message "Skipping NuGet publish (disabled)." } } else { Write-Log -Level "WARN" -Message "Skipping remote tag verification and GitHub release (dev branch)." } #endregion #region Cleanup if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force Write-Log -Level "INFO" -Message " Cleaned up staging directory." } if (Test-Path $testResultsDir) { Remove-Item $testResultsDir -Recurse -Force Write-Log -Level "INFO" -Message " Cleaned up test results directory." } Get-ChildItem -Path $releaseDir -File | Where-Object { $_.Name -like "*$version*.nupkg" -or $_.Name -like "*$version*.snupkg" } | Remove-Item -Force -ErrorAction SilentlyContinue #endregion #region Summary Write-Log -Level "OK" -Message "==================================================" if ($isDevBranch) { Write-Log -Level "OK" -Message "DEV BUILD COMPLETE" } else { Write-Log -Level "OK" -Message "RELEASE COMPLETE" } Write-Log -Level "OK" -Message "==================================================" if (-not $isDevBranch) { Write-Log -Level "STEP" -Message "Release URL: https://github.com/$repo/releases/tag/$tag" } Write-Log -Level "INFO" -Message "Artifacts location: $releaseDir" if ($isDevBranch) { Write-Log -Level "WARN" -Message "To publish to GitHub, switch to '$releaseBranch', merge dev, tag, and run this script again:" Write-Log -Level "WARN" -Message " git checkout $releaseBranch" Write-Log -Level "WARN" -Message " git merge dev" Write-Log -Level "WARN" -Message " git tag v$version" Write-Log -Level "WARN" -Message " .\Release-NuGetPackage.ps1" } #endregion #endregion