diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 680cd0d..535c1f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,20 @@ on: branches: [ main, master, develop ] paths: - 'Sources/**' + - 'release.sh' + - 'release.test.sh' - '.github/workflows/ci.yml' + - '.github/workflows/publish-nuget.yml' + - '.github/workflows/release.yml' pull_request: branches: [ main, master, develop ] paths: - 'Sources/**' + - 'release.sh' + - 'release.test.sh' - '.github/workflows/ci.yml' + - '.github/workflows/publish-nuget.yml' + - '.github/workflows/release.yml' jobs: build-and-test: @@ -41,6 +49,9 @@ jobs: working-directory: ./Sources run: dotnet test --configuration Release --no-build --verbosity normal --logger:"console;verbosity=detailed" + - name: Tests must not write into the repository + run: '[ -z "$(git status --porcelain)" ] || { git status --porcelain; echo "::error::tests wrote into the repository"; exit 1; }' + - name: Pack (dry run) if: matrix.dotnet-version == '10.0.x' working-directory: ./Sources/ModelingEvolution.Drawing @@ -62,4 +73,46 @@ jobs: echo "## CI Build Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- **.NET Version**: ${{ matrix.dotnet-version }}" >> $GITHUB_STEP_SUMMARY - echo "- **Build**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "- **Build**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + build-and-test-arm64: + runs-on: ubuntu-24.04-arm + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + working-directory: ./Sources + run: dotnet restore + + - name: Build + working-directory: ./Sources + run: dotnet build --configuration Release --no-restore + + - name: Test + working-directory: ./Sources + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Tests must not write into the repository + run: '[ -z "$(git status --porcelain)" ] || { git status --porcelain; echo "::error::tests wrote into the repository"; exit 1; }' + + release-script: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Syntax check + run: | + bash -n release.sh + bash -n release.test.sh + + - name: Test release.sh + run: ./release.test.sh diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 7cca505..ad3b053 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -5,10 +5,17 @@ on: tags: - 'ModelingEvolution.Drawing/*.*.*.*' - 'v*.*.*' # Also support v-prefixed tags for compatibility + workflow_call: + inputs: + version: + description: 'Version to publish (X.Y.Z.B or X.Y.Z.B-preview.N)' + required: true + type: string + # Manual re-publish of an EXISTING release tag (e.g. after a failed push): the tag's commit is built. workflow_dispatch: inputs: version: - description: 'Version to publish (e.g., 1.0.53.35)' + description: 'Existing tag version to re-publish (e.g., 1.13.0.71)' required: true type: string @@ -22,7 +29,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - + with: + # A dispatch run needs the tags to find and check out the release tag. + fetch-depth: ${{ github.event_name == 'workflow_dispatch' && '0' || '1' }} + - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -30,11 +40,13 @@ jobs: - name: Set version id: version + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${{ github.event.inputs.version }}" - else - # Extract version from tag + # Resolution order: workflow_call input, workflow_dispatch input (both are `inputs.version`), then the tag. + if [ -n "$INPUT_VERSION" ]; then + VERSION="$INPUT_VERSION" + elif [[ "$GITHUB_REF" == refs/tags/* ]]; then TAG="${GITHUB_REF#refs/tags/}" # Handle both formats: ModelingEvolution.Drawing/1.0.57.35 and v1.0.57.35 if [[ "$TAG" == ModelingEvolution.Drawing/* ]]; then @@ -42,20 +54,48 @@ jobs: else VERSION="${TAG#v}" fi + else + echo "::error::No version: not called with an input and not run on a tag (ref: $GITHUB_REF)" + exit 1 fi + if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){2,3}(-preview\.[0-9]+)?$ ]]; then + echo "::error::Invalid version: $VERSION" + exit 1 + fi + # AssemblyVersion/FileVersion cannot carry a pre-release suffix. + NUMERIC="${VERSION%%-*}" + echo "Version: $VERSION (assembly/file version: $NUMERIC)" echo "VERSION=$VERSION" >> $GITHUB_ENV echo "version=$VERSION" >> $GITHUB_OUTPUT - + echo "numeric=$NUMERIC" >> $GITHUB_OUTPUT + + # Also true when release.yml was itself dispatched and calls this workflow: its tag was just pushed on the + # same commit, so the check passes and the checkout is a no-op. + - name: Check out the release tag (workflow_dispatch only) + if: github.event_name == 'workflow_dispatch' + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + TAG="ModelingEvolution.Drawing/$VERSION" + if ! git rev-parse -q --verify "refs/tags/$TAG" > /dev/null; then + echo "::error::Tag $TAG does not exist; a manual run only re-publishes an existing release tag" + exit 1 + fi + git checkout -q "refs/tags/$TAG" + echo "Building $TAG at $(git rev-parse HEAD)" + - name: Update version in csproj + env: + VERSION: ${{ steps.version.outputs.version }} + NUMERIC: ${{ steps.version.outputs.numeric }} run: | - VERSION="${{ steps.version.outputs.version }}" cd Sources/ModelingEvolution.Drawing - # Update all version-related properties - sed -i "s/.*<\/AssemblyVersion>/$VERSION<\/AssemblyVersion>/" ModelingEvolution.Drawing.csproj - sed -i "s/.*<\/FileVersion>/$VERSION<\/FileVersion>/" ModelingEvolution.Drawing.csproj + sed -i "s/.*<\/AssemblyVersion>/$NUMERIC<\/AssemblyVersion>/" ModelingEvolution.Drawing.csproj + sed -i "s/.*<\/FileVersion>/$NUMERIC<\/FileVersion>/" ModelingEvolution.Drawing.csproj sed -i "s/.*<\/Version>/$VERSION<\/Version>/" ModelingEvolution.Drawing.csproj sed -i "s/.*<\/PackageVersion>/$VERSION<\/PackageVersion>/" ModelingEvolution.Drawing.csproj - + grep -E "<(AssemblyVersion|FileVersion|Version|PackageVersion)>" ModelingEvolution.Drawing.csproj + - name: Restore dependencies working-directory: ./Sources run: dotnet restore @@ -64,9 +104,9 @@ jobs: working-directory: ./Sources run: dotnet build --configuration Release --no-restore - - name: Run tests + - name: Run tests (a red suite publishes nothing) working-directory: ./Sources - run: dotnet test --configuration Release --no-build --verbosity normal || true + run: dotnet test --configuration Release --no-build --verbosity normal - name: Pack working-directory: ./Sources/ModelingEvolution.Drawing diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 021e954..b889132 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,21 @@ -name: Create Release +name: Release + +# Merge to master: bump = the HIGHEST release:major|minor|patch|none label (major > minor > patch > none) over every +# PR merged since the last release tag (first-parent commits lastReleaseTag..HEAD). A PR without a release label +# counts as minor; all PRs release:none = skip; no merged PR for any of those commits = the job fails (releases +# come from labelled PRs). Because the bump covers the whole range, a run cancelled while pending loses nothing: +# the next run carries its bump. +# +# release.sh is the only version implementation; it tags and pushes, and refuses to tag a commit whose history +# lacks the newest release tag. A tag pushed with GITHUB_TOKEN starts no other workflow, so this run calls +# publish-nuget.yml itself with the version release.sh produced. Order: test -> tag -> publish -> GitHub Release. +# +# Recovery: re-run a failed run only while its commit is still master's tip; otherwise do nothing, the next merge +# carries the bump. on: + push: + branches: [ master ] workflow_dispatch: inputs: version_bump: @@ -13,16 +28,28 @@ on: - major - custom custom_version: - description: 'Custom version (only used if version_bump is custom)' + description: 'Custom version X.Y.Z.B (only used if version_bump is custom)' required: false type: string permissions: contents: write + pull-requests: read + +# One run at a time, in order; a pending run replaced by a newer one loses nothing (see above). +concurrency: + group: release-master + cancel-in-progress: false jobs: - create-release: + tag: runs-on: ubuntu-latest + outputs: + skip: ${{ steps.bump.outputs.skip }} + bump: ${{ steps.bump.outputs.bump }} + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} + previous: ${{ steps.release.outputs.previous }} steps: - name: Checkout @@ -30,102 +57,183 @@ jobs: with: fetch-depth: 0 - - name: Get current version from git tags - id: current_version + - name: Configure git user run: | - LATEST_TAG=$(git tag -l "ModelingEvolution.Drawing/*" | sort -V | tail -1) + git config --local user.email "actions@github.com" + git config --local user.name "GitHub Actions" - if [ -n "$LATEST_TAG" ]; then - CURRENT_VERSION="${LATEST_TAG#ModelingEvolution.Drawing/}" + - name: Determine bump + id: bump + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + DISPATCH_BUMP: ${{ github.event.inputs.version_bump }} + CUSTOM_VERSION: ${{ github.event.inputs.custom_version }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -o pipefail + if [ "$EVENT" = "workflow_dispatch" ]; then + BUMP="$DISPATCH_BUMP" + if [ "$BUMP" = "custom" ] && [ -z "$CUSTOM_VERSION" ]; then + echo "::error::version_bump is custom but custom_version is empty" + exit 1 + fi else - CURRENT_VERSION=$(curl -s "https://api.nuget.org/v3-flatcontainer/modelingevolution.drawing/index.json" | jq -r '.versions[-1]' 2>/dev/null || echo "") + LAST=$(git tag -l "ModelingEvolution.Drawing/*" | sed 's#^ModelingEvolution.Drawing/##' \ + | { grep -v -- '-' || true; } | sort -V | tail -1) + if [ -n "$LAST" ]; then + RANGE="refs/tags/ModelingEvolution.Drawing/$LAST..HEAD" + else + RANGE="HEAD" + fi + COMMITS=$(git rev-list --first-parent "$RANGE") + echo "Last release: ${LAST:-none}; first-parent commits since: $(printf '%s' "$COMMITS" | grep -c . || true)" + if [ -z "$COMMITS" ]; then + echo "::error::No commits since the last release tag ($LAST)" + exit 1 + fi + rank() { case "$1" in none) echo 0 ;; patch) echo 1 ;; minor) echo 2 ;; major) echo 3 ;; esac; } + FOUND=0 + BEST=-1 + for C in $COMMITS; do + # The commit -> PR association can lag right after a merge: HEAD (this run's merge) is retried. + # A failing API call fails the job. + ATTEMPTS=1 + [ "$C" = "$SHA" ] && ATTEMPTS=4 + PR="" + for attempt in $(seq 1 "$ATTEMPTS"); do + PR=$(gh api "repos/$REPO/commits/$C/pulls" \ + | jq -c --arg sha "$C" '[.[] | select(.merge_commit_sha == $sha)][0] // empty') + [ -n "$PR" ] && break + if [ "$attempt" -lt "$ATTEMPTS" ]; then + echo "No merged PR for $C yet (attempt $attempt); retrying in 10 s" + sleep 10 + fi + done + if [ -z "$PR" ]; then + if [ "$C" = "$SHA" ]; then + # HEAD is this run's merge: its label decides the bump, so an older PR must never stand in for it. + echo "::error::No merged PR found for HEAD $SHA after $ATTEMPTS attempts; releases come from labelled PRs. Re-run via workflow_dispatch if this was intended." + exit 1 + fi + echo "$C: no merged PR" + continue + fi + FOUND=$((FOUND + 1)) + LABELS=$(echo "$PR" | jq -r '.labels[].name' \ + | { grep -E '^release:(major|minor|patch|none)$' || true; } | sort -u) + COUNT=$(printf '%s' "$LABELS" | grep -c . || true) + NUMBER=$(echo "$PR" | jq -r '.number') + if [ "$COUNT" -gt 1 ]; then + echo "::error::PR #$NUMBER carries more than one release label: ${LABELS//$'\n'/ }" + exit 1 + fi + if [ "$COUNT" -eq 1 ]; then + PR_BUMP="${LABELS#release:}" + else + PR_BUMP="minor" + fi + echo "$C: PR #$NUMBER -> $PR_BUMP" + R=$(rank "$PR_BUMP") + if [ "$R" -gt "$BEST" ]; then BEST=$R; BUMP=$PR_BUMP; fi + done + if [ "$FOUND" -eq 0 ]; then + echo "::error::No merged PR found for $SHA; releases come from labelled PRs. Re-run via workflow_dispatch if this was intended." + exit 1 + fi fi - - if [ -z "$CURRENT_VERSION" ] || [ "$CURRENT_VERSION" = "null" ]; then - CURRENT_VERSION="1.0.0.0" + echo "Bump: $BUMP" + echo "bump=$BUMP" >> "$GITHUB_OUTPUT" + if [ "$BUMP" = "none" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "## Release skipped (release:none)" >> "$GITHUB_STEP_SUMMARY" + else + echo "skip=false" >> "$GITHUB_OUTPUT" fi - echo "Current version: $CURRENT_VERSION (from tag: $LATEST_TAG)" - echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - - - name: Calculate next version - id: next_version + # Nothing is tagged while the suite is red. Tests write outside the repo; a dirty tree here is a defect. + - name: Setup .NET + if: steps.bump.outputs.skip != 'true' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + if: steps.bump.outputs.skip != 'true' + working-directory: ./Sources + run: dotnet restore + + - name: Build + if: steps.bump.outputs.skip != 'true' + working-directory: ./Sources + run: dotnet build --configuration Release --no-restore + + - name: Test + if: steps.bump.outputs.skip != 'true' + working-directory: ./Sources + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Tag with release.sh + id: release + if: steps.bump.outputs.skip != 'true' + env: + BUMP: ${{ steps.bump.outputs.bump }} + CUSTOM_VERSION: ${{ github.event.inputs.custom_version }} run: | - CURRENT_VERSION="${{ steps.current_version.outputs.current_version }}" - VERSION_BUMP="${{ github.event.inputs.version_bump }}" - CUSTOM_VERSION="${{ github.event.inputs.custom_version }}" - - if [ "$VERSION_BUMP" = "custom" ]; then - if [ -z "$CUSTOM_VERSION" ]; then - echo "Error: Custom version is required when version_bump is 'custom'" - exit 1 - fi - NEXT_VERSION="$CUSTOM_VERSION" + if [ "$BUMP" = "custom" ]; then + ./release.sh --version "$CUSTOM_VERSION" --ci else - IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION" - MAJOR="${VERSION_PARTS[0]:-1}" - MINOR="${VERSION_PARTS[1]:-0}" - PATCH="${VERSION_PARTS[2]:-0}" - BUILD="${VERSION_PARTS[3]:-0}" - - case "$VERSION_BUMP" in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - BUILD=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - BUILD=$((BUILD + 1)) - ;; - patch) - PATCH=$((PATCH + 1)) - BUILD=$((BUILD + 1)) - ;; - esac - - NEXT_VERSION="$MAJOR.$MINOR.$PATCH.$BUILD" + ./release.sh "--$BUMP" --ci fi - echo "Next version: $NEXT_VERSION" - echo "next_version=$NEXT_VERSION" >> $GITHUB_OUTPUT - - - name: Create and push tag - run: | - VERSION="${{ steps.next_version.outputs.next_version }}" - git config --local user.email "actions@github.com" - git config --local user.name "GitHub Actions" - git tag -a "ModelingEvolution.Drawing/$VERSION" -m "Release ModelingEvolution.Drawing v$VERSION" - git push origin "ModelingEvolution.Drawing/$VERSION" + publish: + needs: tag + if: needs.tag.outputs.skip != 'true' + uses: ./.github/workflows/publish-nuget.yml + with: + version: ${{ needs.tag.outputs.version }} + secrets: inherit + + # The public GitHub Release exists only after the package was pushed. + github-release: + needs: [ tag, publish ] + if: needs.tag.outputs.skip != 'true' + runs-on: ubuntu-latest + steps: - name: Create GitHub Release uses: ncipollo/release-action@v1 with: - tag: "ModelingEvolution.Drawing/${{ steps.next_version.outputs.next_version }}" - name: "ModelingEvolution.Drawing v${{ steps.next_version.outputs.next_version }}" + tag: ${{ needs.tag.outputs.tag }} + name: "ModelingEvolution.Drawing v${{ needs.tag.outputs.version }}" body: | - ## ModelingEvolution.Drawing v${{ steps.next_version.outputs.next_version }} + ## ModelingEvolution.Drawing v${{ needs.tag.outputs.version }} ### Installation ```bash - dotnet add package ModelingEvolution.Drawing --version ${{ steps.next_version.outputs.next_version }} + dotnet add package ModelingEvolution.Drawing --version ${{ needs.tag.outputs.version }} ``` --- - **Full Changelog**: https://github.com/${{ github.repository }}/compare/ModelingEvolution.Drawing/${{ steps.current_version.outputs.current_version }}...ModelingEvolution.Drawing/${{ steps.next_version.outputs.next_version }} + **Full Changelog**: https://github.com/${{ github.repository }}/compare/ModelingEvolution.Drawing/${{ needs.tag.outputs.previous }}...${{ needs.tag.outputs.tag }} draft: false prerelease: false generateReleaseNotes: true - name: Summary + env: + BUMP: ${{ needs.tag.outputs.bump }} + PREVIOUS: ${{ needs.tag.outputs.previous }} + VERSION: ${{ needs.tag.outputs.version }} + TAG: ${{ needs.tag.outputs.tag }} run: | - echo "## Release Created Successfully" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Previous Version**: ${{ steps.current_version.outputs.current_version }}" >> $GITHUB_STEP_SUMMARY - echo "- **New Version**: ${{ steps.next_version.outputs.next_version }}" >> $GITHUB_STEP_SUMMARY - echo "- **Tag**: ModelingEvolution.Drawing/${{ steps.next_version.outputs.next_version }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The tag push will trigger the publish-nuget workflow to build and push the package to NuGet." >> $GITHUB_STEP_SUMMARY + { + echo "## Released" + echo "" + echo "- **Bump**: $BUMP" + echo "- **Previous Version**: $PREVIOUS" + echo "- **New Version**: $VERSION" + echo "- **Tag**: $TAG" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Sources/ModelingEvolution.Drawing.Tests/BezierFitTests.cs b/Sources/ModelingEvolution.Drawing.Tests/BezierFitTests.cs index 166eea4..6569cc8 100644 --- a/Sources/ModelingEvolution.Drawing.Tests/BezierFitTests.cs +++ b/Sources/ModelingEvolution.Drawing.Tests/BezierFitTests.cs @@ -264,11 +264,8 @@ public void Fit_EmptySpan_Throws() [Fact] public void RenderFit_ToSvg() { - var outputDir = Path.Combine( - Path.GetDirectoryName(typeof(BezierFitTests).Assembly.Location)!, - "..", "..", "..", "..", "..", - "bezier-fit-svg"); - outputDir = Path.GetFullPath(outputDir); + // A test never writes into the repository; the committed gallery is a one-time snapshot. + var outputDir = Path.Combine(Path.GetTempPath(), "ModelingEvolution.Drawing.Tests", "bezier-fit-svg"); Directory.CreateDirectory(outputDir); var cases = new List diff --git a/Sources/ModelingEvolution.Drawing.Tests/CorrectionTests.cs b/Sources/ModelingEvolution.Drawing.Tests/CorrectionTests.cs index 8e17f01..e34141c 100644 --- a/Sources/ModelingEvolution.Drawing.Tests/CorrectionTests.cs +++ b/Sources/ModelingEvolution.Drawing.Tests/CorrectionTests.cs @@ -939,11 +939,8 @@ private void RenderCorrectionSvg( ComplexCurve? perturbedTemplate = null, ComplexCurve? sourceTemplate = null) { - var outputDir = Path.Combine( - Path.GetDirectoryName(typeof(CorrectionTests).Assembly.Location)!, - "..", "..", "..", "..", "..", - "correction-svg"); - outputDir = Path.GetFullPath(outputDir); + // A test never writes into the repository; the committed gallery is a one-time snapshot. + var outputDir = Path.Combine(Path.GetTempPath(), "ModelingEvolution.Drawing.Tests", "correction-svg"); Directory.CreateDirectory(outputDir); var svg = RenderCorrectionCase(template, measured, result.CorrectedCurve, perturbedTemplate); diff --git a/Sources/ModelingEvolution.Drawing.Tests/DensifyTests.cs b/Sources/ModelingEvolution.Drawing.Tests/DensifyTests.cs index 9082f15..183efb0 100644 --- a/Sources/ModelingEvolution.Drawing.Tests/DensifyTests.cs +++ b/Sources/ModelingEvolution.Drawing.Tests/DensifyTests.cs @@ -227,11 +227,8 @@ public void Path_Densify_EmptyPath_ReturnsEmptyPolyline() [Fact] public void RenderDensify_ToSvg() { - var outputDir = Path.Combine( - Path.GetDirectoryName(typeof(DensifyTests).Assembly.Location)!, - "..", "..", "..", "..", "..", - "densify-svg"); - outputDir = Path.GetFullPath(outputDir); + // A test never writes into the repository; the committed gallery is a one-time snapshot. + var outputDir = Path.Combine(Path.GetTempPath(), "ModelingEvolution.Drawing.Tests", "densify-svg"); Directory.CreateDirectory(outputDir); var shapes = new (string name, object original, Polygon? densePoly, Polyline? denseLine)[] diff --git a/release.ps1 b/release.ps1 deleted file mode 100644 index 92ac1d7..0000000 --- a/release.ps1 +++ /dev/null @@ -1,201 +0,0 @@ -# ModelingEvolution.Drawing Release Script for Windows -# Usage: .\release.ps1 [-Patch] [-Minor] [-Major] [-Version X.X.X.X] - -param( - [switch]$Patch = $false, - [switch]$Minor = $false, - [switch]$Major = $false, - [string]$Version = "" -) - -# Colors for output -function Write-Info { Write-Host "[INFO] $args" -ForegroundColor Green } -function Write-Warn { Write-Host "[WARN] $args" -ForegroundColor Yellow } -function Write-Error { Write-Host "[ERROR] $args" -ForegroundColor Red } - -# Function to get the latest version from git tags -function Get-CurrentVersion { - $latestTag = git tag -l "ModelingEvolution.Drawing/*" | Sort-Object -Version | Select-Object -Last 1 - - if ($latestTag) { - # Extract version from tag (format: ModelingEvolution.Drawing/1.0.57.35) - return $latestTag -replace "ModelingEvolution.Drawing/", "" - } - else { - # Fallback to csproj if no tags exist - $csprojPath = "Sources\ModelingEvolution.Drawing\ModelingEvolution.Drawing.csproj" - if (Test-Path $csprojPath) { - $csproj = Get-Content $csprojPath -Raw - if ($csproj -match '([^<]+)') { - return $matches[1] - } - } - return "1.0.57.35" # Default based on known last version - } -} - -# Function to calculate next version -function Get-NextVersion { - param( - [string]$CurrentVersion, - [string]$BumpType - ) - - $parts = $CurrentVersion.Split('.') - $major = [int]$parts[0] - $minor = if ($parts.Length -gt 1) { [int]$parts[1] } else { 0 } - $patch = if ($parts.Length -gt 2) { [int]$parts[2] } else { 0 } - $build = if ($parts.Length -gt 3) { [int]$parts[3] } else { 0 } - - switch ($BumpType) { - "major" { - $major++ - $minor = 0 - $patch = 0 - $build = 0 - } - "minor" { - $minor++ - $patch = 0 - $build++ - } - "patch" { - $patch++ - $build++ - } - default { - Write-Error "Unknown bump type: $BumpType" - exit 1 - } - } - - return "$major.$minor.$patch.$build" -} - -# Function to update version in csproj -function Update-CsprojVersion { - param([string]$Version) - - $csprojPath = "Sources\ModelingEvolution.Drawing\ModelingEvolution.Drawing.csproj" - - if (-not (Test-Path $csprojPath)) { - Write-Error "Could not find $csprojPath" - return $false - } - - Write-Info "Updating version in $csprojPath to $Version" - - $content = Get-Content $csprojPath -Raw - - # Update AssemblyVersion - $content = $content -replace '[^<]+', "$Version" - - # Update FileVersion - $content = $content -replace '[^<]+', "$Version" - - # Update or add Version tag - if ($content -match '') { - $content = $content -replace '[^<]+', "$Version" - } - else { - # Add Version after AssemblyVersion - $content = $content -replace '([^<]+)', "`$1`n $Version" - } - - Set-Content -Path $csprojPath -Value $content -NoNewline - return $true -} - -# Main script -function Main { - # Determine bump type - $bumpType = "patch" # Default - $customVersion = "" - - if ($Major) { $bumpType = "major" } - elseif ($Minor) { $bumpType = "minor" } - elseif ($Patch) { $bumpType = "patch" } - elseif ($Version) { $customVersion = $Version } - - # Ensure we're in a git repository - try { - git rev-parse --git-dir | Out-Null - } - catch { - Write-Error "Not in a git repository" - exit 1 - } - - # Check for uncommitted changes - $gitStatus = git status --porcelain - if ($gitStatus) { - Write-Warn "You have uncommitted changes. Continue anyway? (y/N)" - $response = Read-Host - if ($response -ne 'y' -and $response -ne 'Y') { - Write-Info "Aborted" - exit 0 - } - } - - # Get current version - $currentVersion = Get-CurrentVersion - Write-Info "Current version: $currentVersion" - - # Calculate next version - if ($customVersion) { - $nextVersion = $customVersion - Write-Info "Using custom version: $nextVersion" - } - else { - $nextVersion = Get-NextVersion -CurrentVersion $currentVersion -BumpType $bumpType - Write-Info "Next version ($bumpType bump): $nextVersion" - } - - # Confirm with user - Write-Host "" - Write-Warn "This will:" - Write-Host " 1. Update version in .csproj to $nextVersion" - Write-Host " 2. Commit the changes" - Write-Host " 3. Create tag: ModelingEvolution.Drawing/$nextVersion" - Write-Host " 4. Push changes and tag to origin" - Write-Host "" - Write-Warn "Continue? (y/N)" - $response = Read-Host - - if ($response -ne 'y' -and $response -ne 'Y') { - Write-Info "Aborted" - exit 0 - } - - # Update csproj - if (-not (Update-CsprojVersion -Version $nextVersion)) { - exit 1 - } - - # Commit changes - Write-Info "Committing version update..." - git add Sources/ModelingEvolution.Drawing/ModelingEvolution.Drawing.csproj - git commit -m "Bump version to $nextVersion" - - # Create tag - $tagName = "ModelingEvolution.Drawing/$nextVersion" - Write-Info "Creating tag: $tagName" - git tag -a $tagName -m "Release ModelingEvolution.Drawing v$nextVersion" - - # Push changes and tag - Write-Info "Pushing to origin..." - git push origin HEAD - git push origin $tagName - - Write-Info "✅ Successfully created release $nextVersion" - Write-Host "" - Write-Host "Tag: $tagName" - Write-Host "This will trigger the GitHub Actions workflow to:" - Write-Host " - Create a GitHub release" - Write-Host " - Publish to NuGet" - Write-Host "" - Write-Host "Monitor progress at: https://github.com/modelingevolution/drawing/actions" -} - -# Run main function -Main \ No newline at end of file diff --git a/release.sh b/release.sh old mode 100644 new mode 100755 index 9c1574b..bed2c2b --- a/release.sh +++ b/release.sh @@ -1,113 +1,126 @@ #!/bin/bash - -# ModelingEvolution.Drawing Release Script -# Usage: ./release.sh [--patch|--minor|--major|--version X.X.X.X] # -# Creates a git tag that triggers GitHub Actions to build, inject version via sed, and publish to NuGet. -# The csproj stays at 1.0.0 placeholder — CI/CD handles version injection at build time. +# Release script: computes the next version from git tags, creates the tag and pushes it. +# Publishing is done by GitHub Actions only (publish-nuget.yml); nothing is ever published from a machine. +# +# Usage: ./release.sh [--patch|--minor|--major|--version X.Y.Z.B] [--preview] [--ci] [--dry-run] +# +# --patch bump patch, build+1 (default) +# --minor bump minor, patch=0, build+1 +# --major bump major, minor=0, patch=0, build+1 (build is never reset) +# --version X.Y.Z.B use this exact version +# --preview tag {next}-preview.{N}; allowed from any branch +# --ci no prompts; refuses a dirty tree (untracked files count) +# --dry-run print the computed tag and exit 0 without tagging or pushing +# +# Tags are fetched from origin first. A non-preview release runs on master only. Nothing is tagged (previews +# included) unless the newest release tag is an ancestor of HEAD. Inside GitHub Actions ($GITHUB_OUTPUT set) the script appends +# version=, tag= and previous= to $GITHUB_OUTPUT. +# +# This script is copied unchanged between repositories: only PACKAGE and INITIAL_VERSION differ. + +set -euo pipefail -set -e +PACKAGE="ModelingEvolution.Drawing" +INITIAL_VERSION="1.0.0.0" +RELEASE_BRANCH="master" -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' -NC='\033[0m' # No Color +NC='\033[0m' -# Function to print colored output -print_info() { echo -e "${GREEN}[INFO]${NC} $1"; } -print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } -print_error() { echo -e "${RED}[ERROR]${NC} $1"; } +# Messages go to stderr; stdout carries only the --dry-run result. +print_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; } +print_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; } +print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } -# Function to get the latest version from git tags -get_current_version() { - local latest_tag=$(git tag -l "ModelingEvolution.Drawing/*" | sort -V | tail -1) +usage() { + sed -n '6,14p' "$0" | sed 's/^# \{0,1\}//' +} + +# Version of the newest release tag, empty when there is none; -preview (any pre-release suffix) tags are ignored. +get_latest_release() { + git tag -l "$PACKAGE/*" | sed "s#^$PACKAGE/##" | grep -v -- '-' | sort -V | tail -1 || true +} - if [ -n "$latest_tag" ]; then - echo "${latest_tag#ModelingEvolution.Drawing/}" +get_current_version() { + local latest + latest=$(get_latest_release) + if [ -n "$latest" ]; then + echo "$latest" else - echo "1.0.0.0" + echo "$INITIAL_VERSION" fi } -# Function to calculate next version calculate_next_version() { local current_version=$1 local bump_type=$2 + local major minor patch build - IFS='.' read -ra VERSION_PARTS <<< "$current_version" - local major="${VERSION_PARTS[0]:-1}" - local minor="${VERSION_PARTS[1]:-0}" - local patch="${VERSION_PARTS[2]:-0}" - local build="${VERSION_PARTS[3]:-0}" + IFS='.' read -r major minor patch build <<< "$current_version" + major="${major:-1}" + minor="${minor:-0}" + patch="${patch:-0}" + build="${build:-0}" case "$bump_type" in - major) - major=$((major + 1)) - minor=0 - patch=0 - build=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - build=$((build + 1)) - ;; - patch) - patch=$((patch + 1)) - build=$((build + 1)) - ;; - *) - print_error "Unknown bump type: $bump_type" - exit 1 - ;; + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + *) print_error "Unknown bump type: $bump_type"; exit 1 ;; esac + build=$((build + 1)) echo "$major.$minor.$patch.$build" } -# Main script +# 1 + the highest existing {version}-preview.N +next_preview_number() { + local version=$1 + local highest + highest=$(git tag -l "$PACKAGE/$version-preview.*" | sed "s#^$PACKAGE/$version-preview\.##" \ + | grep -E '^[0-9]+$' | sort -n | tail -1 || true) + echo $(( ${highest:-0} + 1 )) +} + +confirm() { + print_warn "$1 (y/N)" + local response + read -r response + [[ "$response" =~ ^[Yy]$ ]] +} + +monitor_url() { + local url + url=$(git remote get-url origin 2>/dev/null || true) + url="${url%.git}" + url="${url/#git@github.com:/https://github.com/}" + [ -n "$url" ] && echo "$url/actions" +} + main() { local bump_type="patch" local custom_version="" + local version_given=false + local preview=false + local ci=false + local dry_run=false while [[ $# -gt 0 ]]; do case $1 in - --patch) - bump_type="patch" - shift - ;; - --minor) - bump_type="minor" - shift - ;; - --major) - bump_type="major" - shift - ;; + --patch) bump_type="patch"; shift ;; + --minor) bump_type="minor"; shift ;; + --major) bump_type="major"; shift ;; --version) - custom_version="$2" - shift 2 - ;; - -h|--help) - echo "Usage: $0 [--patch|--minor|--major|--version X.X.X.X]" - echo "" - echo "Creates a git tag that triggers GitHub Actions to publish to NuGet." - echo "The csproj version stays at 1.0.0 — CI/CD injects the real version at build time." - echo "" - echo "Options:" - echo " --patch Increment patch version (default)" - echo " --minor Increment minor version" - echo " --major Increment major version" - echo " --version Set a specific version" - echo " -h, --help Show this help message" - exit 0 - ;; - *) - print_error "Unknown option: $1" - echo "Use --help for usage information" - exit 1 - ;; + if [[ $# -lt 2 ]]; then print_error "--version needs a value"; exit 1; fi + custom_version="$2"; version_given=true; shift 2 ;; + --preview) preview=true; shift ;; + --ci) ci=true; shift ;; + --dry-run) dry_run=true; shift ;; + -h|--help) usage; exit 0 ;; + *) print_error "Unknown option: $1"; usage >&2; exit 1 ;; esac done @@ -116,23 +129,55 @@ main() { exit 1 fi - # Check for uncommitted changes - if ! git diff-index --quiet HEAD --; then - print_warn "You have uncommitted changes. Continue anyway? (y/N)" - read -r response - if [[ ! "$response" =~ ^[Yy]$ ]]; then + if [ "$version_given" = true ] && ! [[ "$custom_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + print_error "--version must be X.Y.Z.B, got: $custom_version" + exit 1 + fi + + local branch + branch=$(git rev-parse --abbrev-ref HEAD) + if [ "$preview" = false ] && [ "$branch" != "$RELEASE_BRANCH" ]; then + print_error "A release runs on $RELEASE_BRANCH only (current branch: $branch). Use --preview on a branch." + exit 1 + fi + + if [ -n "$(git status --porcelain)" ]; then + if [ "$ci" = true ]; then + print_error "Uncommitted changes; --ci refuses a dirty tree" + exit 1 + elif [ "$dry_run" = true ]; then + print_warn "Uncommitted changes (ignored by --dry-run)" + elif ! confirm "You have uncommitted changes. Continue anyway?"; then print_info "Aborted" exit 0 fi fi - # Get current version - local current_version=$(get_current_version) + # Stale local tags would give a stale version: fetch them first. + if git remote get-url origin > /dev/null 2>&1; then + if ! git fetch -q --tags origin; then + if [ "$dry_run" = true ] && [ "$ci" = false ]; then + print_warn "Could not fetch tags (local tag differs from origin or origin unreachable); ignored by --dry-run" + else + print_error "Could not fetch tags (local tag differs from origin or origin unreachable)" + exit 1 + fi + fi + fi + + # The newest release must be in HEAD's history, or an older commit would get a higher version. + local latest_release + latest_release=$(get_latest_release) + if [ -n "$latest_release" ] && ! git merge-base --is-ancestor "refs/tags/$PACKAGE/$latest_release" HEAD; then + print_error "The newest release $PACKAGE/$latest_release is not an ancestor of HEAD; refusing to tag an older commit. Merge $RELEASE_BRANCH first." + exit 1 + fi + + local current_version next_version version tag_name + current_version=$(get_current_version) print_info "Current version: $current_version" - # Calculate next version - local next_version - if [ -n "$custom_version" ]; then + if [ "$version_given" = true ]; then next_version="$custom_version" print_info "Using custom version: $next_version" else @@ -140,38 +185,60 @@ main() { print_info "Next version ($bump_type bump): $next_version" fi - # Confirm with user - local tag_name="ModelingEvolution.Drawing/$next_version" - echo "" - print_warn "This will:" - echo " 1. Create tag: $tag_name" - echo " 2. Push tag to origin (triggers GitHub Actions NuGet publish)" - echo "" - print_warn "Continue? (y/N)" - read -r response + version="$next_version" + if [ "$preview" = true ]; then + version="$next_version-preview.$(next_preview_number "$next_version")" + print_info "Preview version: $version" + fi + tag_name="$PACKAGE/$version" + + if git rev-parse -q --verify "refs/tags/$tag_name" > /dev/null; then + print_error "Tag already exists: $tag_name" + exit 1 + fi - if [[ ! "$response" =~ ^[Yy]$ ]]; then - print_info "Aborted" + if [ "$dry_run" = true ]; then + print_info "Dry run: tag $tag_name not created" + echo "$tag_name" exit 0 fi - # Create tag + if [ "$ci" = false ]; then + echo "" >&2 + print_warn "This will:" + echo " 1. Create tag: $tag_name" >&2 + echo " 2. Push tag to origin (triggers the GitHub Actions NuGet publish)" >&2 + echo "" >&2 + if ! confirm "Continue?"; then + print_info "Aborted" + exit 0 + fi + fi + print_info "Creating tag: $tag_name" - git tag -a "$tag_name" -m "Release ModelingEvolution.Drawing v$next_version" + git tag -a "$tag_name" -m "Release $PACKAGE v$version" - # Push tag print_info "Pushing tag to origin..." - git push origin "$tag_name" - - print_info "Successfully created release $next_version" - echo "" - echo "Tag: $tag_name" - echo "GitHub Actions will now:" - echo " - Inject version $next_version into csproj via sed" - echo " - Build and test" - echo " - Publish to NuGet" - echo "" - echo "Monitor progress at: https://github.com/modelingevolution/drawing/actions" + if ! git push origin "refs/tags/$tag_name"; then + # Drop the local tag so a retry is not refused with "Tag already exists". + git tag -d "$tag_name" > /dev/null + print_error "Push of $tag_name failed; local tag removed" + exit 1 + fi + + if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "version=$version" + echo "tag=$tag_name" + echo "previous=$current_version" + } >> "$GITHUB_OUTPUT" + fi + + print_info "Created $tag_name" + local url + url=$(monitor_url || true) + [ -n "$url" ] && print_info "Monitor at: $url" + exit 0 } main "$@" diff --git a/release.test.sh b/release.test.sh new file mode 100755 index 0000000..ee0f668 --- /dev/null +++ b/release.test.sh @@ -0,0 +1,167 @@ +#!/bin/bash +# +# Tests for release.sh. Plain bash: builds a throw-away git repo (with a bare "origin"), plants tags and +# asserts the computed tags with --dry-run, plus one real tag+push into the bare origin. +# +# Usage: ./release.test.sh + +set -uo pipefail + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release.sh" +PACKAGE=$(sed -n 's/^PACKAGE="\(.*\)"$/\1/p' "$SCRIPT") +unset GITHUB_OUTPUT + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +failures=0 +passes=0 + +pass() { passes=$((passes + 1)); echo "PASS: $1"; } +fail() { failures=$((failures + 1)); echo "FAIL: $1"; } + +# expect_tag +expect_tag() { + local description=$1 expected="$PACKAGE/$2" + shift 2 + local actual status + actual=$("$SCRIPT" "$@" 2> "$WORK/stderr" < /dev/null) + status=$? + if [ "$status" -eq 0 ] && [ "$actual" = "$expected" ]; then + pass "$description -> $actual" + else + fail "$description: expected '$expected' exit 0, got '$actual' exit $status" + sed 's/^/ /' "$WORK/stderr" + fi +} + +# expect_exit +expect_exit() { + local description=$1 expected=$2 + shift 2 + local status + "$SCRIPT" "$@" > /dev/null 2> "$WORK/stderr" < /dev/null + status=$? + if [ "$status" -eq "$expected" ]; then + pass "$description (exit $status)" + else + fail "$description: expected exit $expected, got $status" + sed 's/^/ /' "$WORK/stderr" + fi +} + +git init -q --bare "$WORK/origin.git" +git init -q -b master "$WORK/repo" +cd "$WORK/repo" || exit 1 +git config user.email "test@example.com" +git config user.name "release.test.sh" +git config commit.gpgsign false +git config tag.gpgsign false +git remote add origin "$WORK/origin.git" +echo "a" > file.txt +git add file.txt +git commit -q -m "initial" +git push -q origin master + +# No tag yet: INITIAL_VERSION is the current version. +initial=$(sed -n 's/^INITIAL_VERSION="\(.*\)"$/\1/p' "$SCRIPT") +IFS='.' read -r ma mi pa bu <<< "$initial" +expect_tag "no tags: --patch bumps INITIAL_VERSION" "$ma.$mi.$((pa + 1)).$((bu + 1))" --patch --ci --dry-run + +git tag -a "$PACKAGE/1.9.9.76" -m t +git tag -a "$PACKAGE/1.13.0.71" -m t +git tag -a "$PACKAGE/1.14.0.72-preview.1" -m t +git tag -a "$PACKAGE/2.0.0.99-preview.1" -m t + +"$SCRIPT" --dry-run --ci > /dev/null 2> "$WORK/stderr" < /dev/null +if grep -q "Current version: 1.13.0.71" "$WORK/stderr"; then + pass "current version ignores -preview tags and sorts by version (1.13.0.71)" +else + fail "current version: $(grep 'Current version' "$WORK/stderr")" +fi + +expect_tag "default bump is patch" "1.13.1.72" --ci --dry-run +expect_tag "--patch" "1.13.1.72" --patch --ci --dry-run +expect_tag "--minor" "1.14.0.72" --minor --ci --dry-run +expect_tag "--major keeps build+1" "2.0.0.72" --major --ci --dry-run +expect_tag "--version" "1.20.0.80" --version 1.20.0.80 --ci --dry-run +expect_tag "--preview --minor counts existing previews" "1.14.0.72-preview.2" --preview --minor --ci --dry-run +expect_tag "--preview --patch, first preview" "1.13.1.72-preview.1" --preview --patch --ci --dry-run +expect_tag "--dry-run without --ci needs no prompt" "1.14.0.72" --minor --dry-run + +expect_exit "existing tag is refused" 1 --version 1.13.0.71 --ci --dry-run +expect_exit "malformed --version is refused" 1 --version 1.2.3 --ci --dry-run +expect_exit "empty --version is refused" 1 --version '' --ci --dry-run +expect_exit "--version without a value is refused" 1 --ci --dry-run --version +expect_exit "unknown option is refused" 1 --bogus + +echo "b" >> file.txt +expect_exit "--ci on a dirty tree" 1 --minor --ci --dry-run +expect_exit "--preview --ci on a dirty tree" 1 --preview --minor --ci --dry-run +git checkout -q -- file.txt +touch untracked.txt +expect_exit "--ci with an untracked file" 1 --minor --ci --dry-run +rm untracked.txt + +git checkout -q -b feature/x +expect_exit "release off master (--ci)" 1 --minor --ci --dry-run +expect_exit "release off master (interactive)" 1 --minor --dry-run +expect_tag "--preview off master" "1.14.0.72-preview.2" --preview --minor --ci --dry-run +git checkout -q master + +# The newest release tag must be an ancestor of HEAD (previews included). +git checkout -q -b side +echo "side" > side.txt +git add side.txt +git commit -q -m "side" +git tag -a "$PACKAGE/1.13.9.79" -m t +git checkout -q master +expect_exit "newest release tag not an ancestor of HEAD" 1 --patch --ci --dry-run +expect_exit "newest release tag not an ancestor of HEAD (--preview)" 1 --preview --patch --ci --dry-run +git merge -q --no-ff -m "merge side" side +expect_tag "newest release tag on an ancestor is accepted" "1.13.10.80" --patch --ci --dry-run +git tag -d "$PACKAGE/1.13.9.79" > /dev/null +git reset -q --hard HEAD~1 +git branch -q -D side + +# Tags are fetched from origin: a tag that exists only on origin moves the version. +git clone -q "$WORK/origin.git" "$WORK/other" +git -C "$WORK/other" -c user.email=test@example.com -c user.name=other -c tag.gpgsign=false \ + tag -a "$PACKAGE/1.13.5.75" -m t +git -C "$WORK/other" push -q origin "refs/tags/$PACKAGE/1.13.5.75" +expect_tag "tags are fetched from origin first" "1.13.6.76" --patch --ci --dry-run +git -C "$WORK/other" push -q origin ":refs/tags/$PACKAGE/1.13.5.75" +git tag -d "$PACKAGE/1.13.5.75" > /dev/null + +# An unreachable origin: --dry-run alone warns, --ci (dry or real) exits 1. +git remote set-url origin "$WORK/missing.git" +expect_tag "fetch failure is a warning under --dry-run alone" "1.13.1.72" --patch --dry-run +expect_exit "fetch failure under --ci --dry-run" 1 --patch --ci --dry-run +expect_exit "fetch failure under --ci" 1 --patch --ci +git remote set-url origin "$WORK/origin.git" + +# A real run: tags, pushes to origin, writes $GITHUB_OUTPUT. +out="$WORK/github_output" +: > "$out" +if GITHUB_OUTPUT="$out" "$SCRIPT" --minor --ci > /dev/null 2> "$WORK/stderr" < /dev/null; then + pass "--minor --ci tags and pushes" +else + fail "--minor --ci real run" + sed 's/^/ /' "$WORK/stderr" +fi +if git --git-dir="$WORK/origin.git" rev-parse -q --verify "refs/tags/$PACKAGE/1.14.0.72" > /dev/null; then + pass "tag $PACKAGE/1.14.0.72 is on origin" +else + fail "tag $PACKAGE/1.14.0.72 is not on origin" +fi +expected_output=$(printf 'version=1.14.0.72\ntag=%s/1.14.0.72\nprevious=1.13.0.71' "$PACKAGE") +if [ "$(cat "$out")" = "$expected_output" ]; then + pass "\$GITHUB_OUTPUT has version, tag and previous" +else + fail "\$GITHUB_OUTPUT content: $(cat "$out")" +fi +expect_tag "next --patch after the release" "1.14.1.73" --patch --ci --dry-run + +echo "" +echo "$passes passed, $failures failed" +[ "$failures" -eq 0 ]