diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md index f6ca16b..2549402 100644 --- a/.github/RELEASE_TEMPLATE.md +++ b/.github/RELEASE_TEMPLATE.md @@ -5,6 +5,8 @@ **Tag:** `vX.Y.Z` +**Direct download:** [delphi-inspect.ps1](https://github.com/continuous-delphi/delphi-inspect/releases/download/vX.Y.Z/delphi-inspect.ps1) + This release improves `delphi-inspect`, a Continuous Delphi utility that scans for installed Delphi compilers/toolchains and outputs standardized, reliable metadata for use in CI environments, build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4c67a19 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release + +on: + push: + tags: + - 'v*' + +concurrency: + group: tests-refs/heads/${{ github.event.repository.default_branch }} + cancel-in-progress: true + +jobs: + release: + runs-on: windows-latest + permissions: + contents: write + + steps: + # Tag validation runs before checkout -- no workspace is needed for this check. + # The glob above is intentionally loose; this step is the authoritative gate. + - name: Validate tag format + shell: bash + run: | + if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Tag '${GITHUB_REF_NAME}' does not match required format vX.Y.Z" + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Install Pester and PSScriptAnalyzer + run: | + pwsh -Command "Install-Module Pester -MinimumVersion 5.7.0 -Force -Scope CurrentUser" + pwsh -Command "Install-Module PSScriptAnalyzer -Force -Scope CurrentUser" + + - name: Run tests + shell: pwsh + env: + TERM: dumb + NO_COLOR: "1" + run: | + $PSStyle.OutputRendering = 'PlainText' + ./tests/run-tests.ps1 + + - name: Extract version from tag + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + + - name: Populate release notes + shell: bash + run: | + if [[ ! -f .github/RELEASE_TEMPLATE.md ]]; then + echo "ERROR: Missing .github/RELEASE_TEMPLATE.md" + exit 1 + fi + + CHANGELOG_SECTION=$(awk '/^## \['"${VERSION}"'\]/{found=1; next} /^## \[/{if(found) exit} found' CHANGELOG.md) + + if [[ -z "${CHANGELOG_SECTION}" ]]; then + echo "ERROR: No entry for version ${VERSION} found in CHANGELOG.md" + exit 1 + fi + + sed "s/vX\.Y\.Z/v${VERSION}/g; s/X\.Y\.Z/${VERSION}/g" \ + .github/RELEASE_TEMPLATE.md > release-notes.md + + printf '\n---\n\n# Change Log\n\n%s\n' "${CHANGELOG_SECTION}" >> release-notes.md + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + REPO_NAME="${GITHUB_REPOSITORY##*/}" + gh release create "${GITHUB_REF_NAME}" \ + --title "${REPO_NAME} v${VERSION}" \ + --notes-file release-notes.md \ + source/delphi-inspect.ps1 diff --git a/docs/tag-release-usage.md b/docs/tag-release-usage.md new file mode 100644 index 0000000..faa0cd2 --- /dev/null +++ b/docs/tag-release-usage.md @@ -0,0 +1,328 @@ +# tag-release.ps1 -- Usage Reference + +Release tagging script for `delphi-inspect`. +Located at `tools/tag-release.ps1`. + +## Release checklist + +Before running the script, ensure: + +- [ ] All changes have been committed to the default branch and pushed to origin +- [ ] All tests pass: `pwsh tests/run-tests.ps1` +- [ ] `$ToolVersion` in `source/delphi-inspect.ps1` has been updated to `X.Y.Z` +- [ ] `CHANGELOG.md` has an entry for `[X.Y.Z]` + +Then: + +```powershell +pwsh tools/tag-release.ps1 -Version X.Y.Z -WhatIf # dry run first +pwsh tools/tag-release.ps1 -Version X.Y.Z # create and push tag +``` + +_Reminder_: The actual workflow trigger can be delayed by a few minutes. + +## Synopsis + +```powershell +pwsh tools/tag-release.ps1 -Version [-SkipBranchCheck] [-WhatIf] [-Confirm] +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `-Version` | Yes | Semantic version to tag, e.g. `1.0.0`. Must match `X.Y.Z` format exactly. | +| `-SkipBranchCheck` | No | Bypasses the branch guard. All other checks still run. Use only when hotfixing from a non-default branch. | +| `-WhatIf` | No | Runs all precondition checks but does not create or push the tag. Echoes the tag name and message that would be used. | +| `-Confirm` | No | Prompts for confirmation before creating and pushing the tag. | + +## Preconditions checked + +The script validates all of the following before touching git: + +1. `$ToolVersion` in `source/delphi-inspect.ps1` matches the `-Version` argument +2. `CHANGELOG.md` has a `## [X.Y.Z]` entry for the version +3. `git` is available on PATH +4. Script is running inside a git repository +5. Current branch matches the default branch derived from `origin/HEAD` +6. Working tree is clean (no uncommitted changes) +7. `origin` remote exists +8. Tags are fetched from origin (local tag list brought up to date) +9. Local HEAD is up-to-date with `origin/` +10. Tag does not already exist (checked locally after fetch, covering both local and origin) + +If any check fails the script exits immediately with a descriptive error message. +No git operations are performed until all checks pass. + +--- + +## Examples + +### Normal release + +```powershell +pwsh tools/tag-release.ps1 -Version 1.0.0 +``` + +```text + +delphi-inspect tag-release +=========================== + Version : 1.0.0 + Tag : v1.0.0 + Repo : C:\dev\delphi-inspect + + ok script version matches (1.0.0) + ok CHANGELOG.md has entry for 1.0.0 + ok git found (git version 2.47.0.windows.1) + ok inside git repository + ok on branch 'main' + ok working tree is clean + ok origin remote found + ok tags fetched + ok HEAD is up-to-date with origin/main + ok tag 'v1.0.0' does not exist + +All checks passed. + + Creating tag v1.0.0... + ok tag created + Pushing tag to origin... + ok tag pushed + +Released: v1.0.0 +The GitHub Actions release workflow should run for this tag. +``` + +--- + +### Dry run with `-WhatIf` + +Runs all precondition checks but does not create or push the tag. +Use this to validate repo state before committing to the release. + +```powershell +pwsh tools/tag-release.ps1 -Version 1.0.0 -WhatIf +``` + +```text + ok script version matches (1.0.0) + ok CHANGELOG.md has entry for 1.0.0 + ok git found (git version 2.47.0.windows.1) + ok inside git repository + ok on branch 'main' + ok working tree is clean + ok origin remote found + ok tags fetched + ok HEAD is up-to-date with origin/main + ok tag 'v1.0.0' does not exist + +All checks passed. + + WhatIf: would create annotated tag and push to origin + Tag : v1.0.0 + Message: Release v1.0.0 +``` + +--- + +### Interactive confirmation with `-Confirm` + +Prompts before the git operations. Useful when you want all checks to run +first and then explicitly approve the push. + +```powershell +pwsh tools/tag-release.ps1 -Version 1.0.0 -Confirm +``` + +```text + ...all checks... + +All checks passed. + +Confirm +Are you sure you want to perform this action? + Create annotated tag and push + Target: origin (tag: v1.0.0 message: 'Release v1.0.0') +[Y] Yes [A] Yes to All [N] No [L] No to All [S] Suspend [?] Help (default is "Y"): +``` + +--- + +### Script version mismatch + +```powershell +pwsh tools/tag-release.ps1 -Version 1.1.0 +``` + +```text +FAIL $ToolVersion in delphi-inspect.ps1 is '1.0.0' but -Version arg is '1.1.0'. + Update $ToolVersion in the script and commit before tagging. +``` + +Update `$ToolVersion = '1.1.0'` in `source/delphi-inspect.ps1`, commit, push, then re-run. + +--- + +### Missing CHANGELOG entry + +```powershell +pwsh tools/tag-release.ps1 -Version 1.1.0 +``` + +```text +FAIL No entry for version 1.1.0 found in CHANGELOG.md. + Add a '## [1.1.0]' section before tagging. +``` + +Add a `## [1.1.0]` section to `CHANGELOG.md`, commit, push, then re-run. + +--- + +### Wrong format -- missing patch segment + +```powershell +pwsh tools/tag-release.ps1 -Version 1.0 +``` + +```text +tag-release.ps1: Cannot validate argument on parameter 'Version'. The argument +"1.0" does not match the "^[0-9]+\.[0-9]+\.[0-9]+$" pattern. +``` + +Rejected immediately by `ValidatePattern` before any code runs. + +--- + +### Dirty working tree + +```powershell +pwsh tools/tag-release.ps1 -Version 1.0.0 +``` + +```text +FAIL Working tree is not clean. Commit or stash all changes before tagging. + + M source/delphi-inspect.ps1 +``` + +Commit or stash all changes, then re-run. + +--- + +### Not on the default branch + +```powershell +# run from a feature branch +pwsh tools/tag-release.ps1 -Version 1.0.0 +``` + +```text +FAIL Must be on 'main' branch to tag a release (currently on 'feature/add-locate'). + Switch to main, or use -SkipBranchCheck to override (not recommended). +``` + +--- + +### `origin/HEAD` not set -- fallback warning + +If `origin/HEAD` is not configured (e.g. in a manually initialised remote), +the script warns and assumes `main`: + +```text + warn origin/HEAD not set; assuming default branch is 'main' +``` + +To resolve: `git remote set-head origin -a` + +--- + +### Local HEAD behind origin + +```powershell +pwsh tools/tag-release.ps1 -Version 1.0.0 +``` + +```text +FAIL Local HEAD is 2 commit(s) behind origin/main. Run 'git pull' before tagging. +``` + +--- + +### Local HEAD ahead of origin + +```powershell +pwsh tools/tag-release.ps1 -Version 1.0.0 +``` + +```text +FAIL Local HEAD is 1 commit(s) ahead of origin/main. Push your changes before tagging. +``` + +--- + +### Tag already exists + +After a fetch, the single tag check covers both local and origin: + +```powershell +pwsh tools/tag-release.ps1 -Version 1.0.0 +``` + +```text +FAIL Tag 'v1.0.0' already exists (local or origin). + To delete locally: git tag -d v1.0.0 + To delete on origin: git push origin --delete v1.0.0 +``` + +--- + +### Emergency override -- tagging from a non-default branch + +Use `-SkipBranchCheck` only when a hotfix must be released from a branch other +than the default. All other preconditions still run. + +```powershell +pwsh tools/tag-release.ps1 -Version 1.0.1 -SkipBranchCheck +``` + +```text + ok script version matches (1.0.1) + ok CHANGELOG.md has entry for 1.0.1 + ok git found (git version 2.47.0.windows.1) + ok inside git repository + warn Not on 'main' (on 'hotfix/registry-path'); -SkipBranchCheck override active + ok working tree is clean + ok origin remote found + ok tags fetched + ok HEAD is up-to-date with origin/main + ok tag 'v1.0.1' does not exist + +All checks passed. + + Creating tag v1.0.1... + ok tag created + Pushing tag to origin... + ok tag pushed + +Released: v1.0.1 +The GitHub Actions release workflow should run for this tag. +``` + +--- + +### Push fails after tag is created + +If the local tag is created but the push to origin fails, the script prints +cleanup guidance before rethrowing the error: + +```text +ERROR: Tag/push failed. + + +Partial failure - check the state and clean up if needed: + Local tag exists. If the push failed, delete it with: + git tag -d v1.0.0 + Verify origin does not have a partial push: + git ls-remote --tags origin refs/tags/v1.0.0 +``` diff --git a/tools/tag-release.ps1 b/tools/tag-release.ps1 new file mode 100644 index 0000000..d9fc5d1 --- /dev/null +++ b/tools/tag-release.ps1 @@ -0,0 +1,307 @@ +# tools/tag-release.ps1 +# Creates and pushes a vX.Y.Z release tag for delphi-inspect. +# Requires: PowerShell 7+, git (on PATH) +# +# Usage: +# pwsh tools/tag-release.ps1 -Version 1.0.0 +# +# The script validates preconditions before touching git: +# - Version argument matches X.Y.Z semver format +# - $ToolVersion in delphi-inspect.ps1 matches the Version argument +# - CHANGELOG.md has an entry for the version +# - Working tree is clean (no uncommitted changes) +# - Current branch matches the default branch on origin +# - Local HEAD is up-to-date with origin/ +# - Tag does not already exist locally or on origin + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'Write-Host is required for colored interactive console output.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPositionalParameters', '', + Justification = 'Invoke-Git uses ValueFromRemainingArguments for variadic git args; positional use is intentional.')] +param( + [Parameter(Mandatory=$true, HelpMessage='Semantic version to tag, e.g. 1.0.0')] + [ValidatePattern('^[0-9]+\.[0-9]+\.[0-9]+$')] + [string] $Version, + + [Parameter(HelpMessage='Skip the branch check (use when hotfixing from a non-default branch)')] + [switch] $SkipBranchCheck +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-Step([string]$Message) { + Write-Host " $Message" -ForegroundColor Cyan +} + +function Write-Ok([string]$Message) { + Write-Host " ok $Message" -ForegroundColor Green +} + +function Fail([string]$Message) { + Write-Host "" + Write-Host "FAIL $Message" -ForegroundColor Red + Write-Host "" + throw $Message +} + +function Invoke-Git { + [CmdletBinding()] + param([Parameter(ValueFromRemainingArguments)][string[]] $GitArgs) + + $result = & git @GitArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + Fail "git $($GitArgs -join ' ') failed (exit $LASTEXITCODE):`n$result" + } + return $result +} + +# --------------------------------------------------------------------------- +# Resolve paths relative to the repo root (script is in tools/) +# --------------------------------------------------------------------------- + +$repoRoot = (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path +$scriptFile = Join-Path $repoRoot 'source' 'delphi-inspect.ps1' +$changelogFile = Join-Path $repoRoot 'CHANGELOG.md' +$tag = "v$Version" + +Write-Host "" +Write-Host "delphi-inspect tag-release" -ForegroundColor White +Write-Host "===========================" -ForegroundColor White +Write-Host " Version : $Version" +Write-Host " Tag : $tag" +Write-Host " Repo : $repoRoot" +Write-Host "" + +# --------------------------------------------------------------------------- +# Precondition 1: script version matches the Version argument +# --------------------------------------------------------------------------- + +Write-Step "Checking script version..." + +if (-not (Test-Path -LiteralPath $scriptFile)) { + Fail "Script not found: $scriptFile" +} + +$scriptContent = Get-Content -LiteralPath $scriptFile -Raw +if ($scriptContent -notmatch '\$ToolVersion\s*=\s*''([^'']+)''') { + Fail "Could not find '`$ToolVersion = ''...''' in delphi-inspect.ps1." +} + +$scriptVersion = $Matches[1] +if ($scriptVersion -ne $Version) { + Fail "`$ToolVersion in delphi-inspect.ps1 is '$scriptVersion' but -Version arg is '$Version'.`n Update `$ToolVersion in the script and commit before tagging." +} + +Write-Ok "script version matches ($scriptVersion)" + +# --------------------------------------------------------------------------- +# Precondition 2: CHANGELOG.md has an entry for the version +# --------------------------------------------------------------------------- + +Write-Step "Checking CHANGELOG.md..." + +if (-not (Test-Path -LiteralPath $changelogFile)) { + Fail "CHANGELOG.md not found: $changelogFile" +} + +$changelogContent = Get-Content -LiteralPath $changelogFile -Raw +if ($changelogContent -notmatch "(?m)^\#\# \[$([regex]::Escape($Version))\]") { + Fail "No entry for version $Version found in CHANGELOG.md.`n Add a '## [$Version]' section before tagging." +} + +Write-Ok "CHANGELOG.md has entry for $Version" + +# --------------------------------------------------------------------------- +# Precondition 3: git is available +# --------------------------------------------------------------------------- + +Write-Step "Checking git..." + +try { + $gitVersion = & git --version 2>&1 + if ($LASTEXITCODE -ne 0) { throw } +} catch { + Fail "git is not available on PATH." +} + +Write-Ok "git found ($gitVersion)" + +Push-Location $repoRoot +try { + + # ------------------------------------------------------------------------- + # Precondition 4: inside a git repository + # ------------------------------------------------------------------------- + + Write-Step "Checking git repository..." + + $null = & git rev-parse --git-dir 2>&1 + if ($LASTEXITCODE -ne 0) { + Fail "Not inside a git repository: $repoRoot" + } + + Write-Ok "inside git repository" + + # ------------------------------------------------------------------------- + # Precondition 5: current branch matches origin's default branch + # ------------------------------------------------------------------------- + + Write-Step "Checking branch..." + + $branch = (Invoke-Git rev-parse --abbrev-ref HEAD).Trim() + + $originHead = & git rev-parse --abbrev-ref origin/HEAD 2>$null + $defaultBranch = if ($LASTEXITCODE -eq 0 -and $originHead) { + $originHead.Trim() -replace '^origin/', '' + } else { + Write-Host " warn origin/HEAD not set; assuming default branch is 'main'" -ForegroundColor Yellow + 'main' + } + + if (-not $SkipBranchCheck -and $branch -ne $defaultBranch) { + Fail "Must be on '$defaultBranch' branch to tag a release (currently on '$branch').`n Switch to $defaultBranch, or use -SkipBranchCheck to override (not recommended)." + } + + if ($SkipBranchCheck -and $branch -ne $defaultBranch) { + Write-Host " warn Not on '$defaultBranch' (on '$branch'); -SkipBranchCheck override active" -ForegroundColor Yellow + } else { + Write-Ok "on branch '$defaultBranch'" + } + + # ------------------------------------------------------------------------- + # Precondition 6: working tree is clean + # ------------------------------------------------------------------------- + + Write-Step "Checking working tree..." + + $status = Invoke-Git status --porcelain + if ($status) { + Fail "Working tree is not clean. Commit or stash all changes before tagging.`n`n$status" + } + + Write-Ok "working tree is clean" + + # ------------------------------------------------------------------------- + # Precondition 7: origin remote exists + # ------------------------------------------------------------------------- + + Write-Step "Checking for origin remote..." + + $remotes = (Invoke-Git remote).Trim().Split([Environment]::NewLine) + if ($remotes -notcontains 'origin') { + Fail "Remote 'origin' not found. Add it or run this script in a clone with an origin remote." + } + + Write-Ok "origin remote found" + + # ------------------------------------------------------------------------- + # Fetch tags from origin so the local tag list is current + # ------------------------------------------------------------------------- + + Write-Step "Fetching tags from origin..." + Invoke-Git fetch --tags origin | Out-Null + Write-Ok "tags fetched" + + # ------------------------------------------------------------------------- + # Precondition 8: local HEAD is not behind origin/ + # ------------------------------------------------------------------------- + + Write-Step "Checking HEAD is up-to-date with origin/$defaultBranch..." + + $localRev = (Invoke-Git rev-parse HEAD).Trim() + $remoteRev = (Invoke-Git rev-parse "origin/$defaultBranch").Trim() + + if ($localRev -ne $remoteRev) { + $behind = (Invoke-Git rev-list --count "HEAD..origin/$defaultBranch").Trim() + $ahead = (Invoke-Git rev-list --count "origin/$defaultBranch..HEAD").Trim() + + if ([int]$behind -gt 0 -and [int]$ahead -eq 0) { + Fail "Local HEAD is $behind commit(s) behind origin/$defaultBranch. Run 'git pull' before tagging." + } elseif ([int]$ahead -gt 0 -and [int]$behind -eq 0) { + Fail "Local HEAD is $ahead commit(s) ahead of origin/$defaultBranch. Push your changes before tagging." + } else { + Fail "Local HEAD has diverged from origin/$defaultBranch ($ahead ahead, $behind behind). Reconcile before tagging." + } + } + + Write-Ok "HEAD is up-to-date with origin/$defaultBranch" + + # ------------------------------------------------------------------------- + # Precondition 9: tag does not already exist (local or origin) + # ------------------------------------------------------------------------- + + Write-Step "Checking for existing tag..." + + & git show-ref --tags --verify --quiet "refs/tags/$tag" 2>$null + if ($LASTEXITCODE -eq 0) { + Fail "Tag '$tag' already exists (local or origin).`n To delete locally: git tag -d $tag`n To delete on origin: git push origin --delete $tag" + } + + Write-Ok "tag '$tag' does not exist" + + # ------------------------------------------------------------------------- + # All preconditions passed - confirm and tag + # ------------------------------------------------------------------------- + + $tagMsg = "Release $tag" + + Write-Host "" + Write-Host "All checks passed." -ForegroundColor Green + Write-Host "" + + if ($PSCmdlet.ShouldProcess( + "origin (tag: $tag message: '$tagMsg')", + "Create annotated tag and push")) { + + try { + + Write-Step "Creating tag $tag..." + Invoke-Git tag -a $tag -m $tagMsg + Write-Ok "tag created" + + Write-Step "Pushing tag to origin..." + Invoke-Git push origin $tag | Out-Null + Write-Ok "tag pushed" + + Write-Host "" + Write-Host "Released: $tag" -ForegroundColor Green + Write-Host "The GitHub Actions release workflow should run for this tag." -ForegroundColor Green + Write-Host "" + + } catch { + + Write-Host "" + Write-Host "ERROR: Tag/push failed." -ForegroundColor Red + Write-Host $_ -ForegroundColor DarkRed + Write-Host "" + Write-Host "Partial failure - check the state and clean up if needed:" -ForegroundColor Yellow + + & git show-ref --tags --verify --quiet "refs/tags/$tag" 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host " Local tag exists. If the push failed, delete it with:" -ForegroundColor Yellow + Write-Host " git tag -d $tag" -ForegroundColor Yellow + } + + Write-Host " Verify origin does not have a partial push:" -ForegroundColor Yellow + Write-Host " git ls-remote --tags origin refs/tags/$tag" -ForegroundColor Yellow + Write-Host "" + throw + + } + + } else { + Write-Host " WhatIf: would create annotated tag and push to origin" -ForegroundColor Yellow + Write-Host " Tag : $tag" -ForegroundColor Yellow + Write-Host " Message: $tagMsg" -ForegroundColor Yellow + Write-Host "" + } + +} finally { + Pop-Location +}