From aff4e77edb2d8324040611f9611f15e5312c4738 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sat, 24 Oct 2026 12:18:28 +0000 Subject: [PATCH 1/6] epic-129 S1: release path to ADR-9 - release.sh: --ci (no prompts, refuses a dirty tree), --preview ({next}-preview.{N}, any branch), --dry-run; current version ignores -preview tags; --major keeps build+1 (1.13.0.71 -> 2.0.0.72); a release runs on master only; writes version/tag/previous to $GITHUB_OUTPUT; parameterised by PACKAGE and INITIAL_VERSION so matrix copies it unchanged. - release.test.sh: plain-bash tests in a throw-away repo. - release.yml: on push master, bump from the merged PR's release:* label (default minor, release:none skips), runs release.sh -- --ci, then calls publish-nuget.yml in the same run. - publish-nuget.yml: workflow_call trigger with a version input; tests gate the push (no more || true); preview suffix stripped for Assembly/FileVersion. - ci.yml: ubuntu-24.04-arm job, release-script job, paths cover the release files. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 49 +++++- .github/workflows/publish-nuget.yml | 45 +++-- .github/workflows/release.yml | 172 ++++++++++-------- release.sh | 259 ++++++++++++++++------------ release.test.sh | 130 ++++++++++++++ 5 files changed, 456 insertions(+), 199 deletions(-) mode change 100644 => 100755 release.sh create mode 100755 release.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 680cd0d..9b548b7 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: @@ -62,4 +70,43 @@ 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 + + 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..aa8e07f 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -5,6 +5,12 @@ 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 workflow_dispatch: inputs: version: @@ -30,11 +36,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 +50,33 @@ 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 + - 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 +85,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..ef369ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,12 @@ -name: Create Release +name: Release + +# Merge to master: bump = the merged PR's label release:major|minor|patch|none (default minor). +# release.sh is the only version implementation; it tags and pushes. A tag pushed with GITHUB_TOKEN starts +# no other workflow, so this run calls publish-nuget.yml itself with the version release.sh produced. on: + push: + branches: [ master ] workflow_dispatch: inputs: version_bump: @@ -13,16 +19,26 @@ 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 + +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 }} steps: - name: Checkout @@ -30,102 +46,108 @@ 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) - - if [ -n "$LATEST_TAG" ]; then - CURRENT_VERSION="${LATEST_TAG#ModelingEvolution.Drawing/}" - else - CURRENT_VERSION=$(curl -s "https://api.nuget.org/v3-flatcontainer/modelingevolution.drawing/index.json" | jq -r '.versions[-1]' 2>/dev/null || echo "") - fi - - if [ -z "$CURRENT_VERSION" ] || [ "$CURRENT_VERSION" = "null" ]; then - CURRENT_VERSION="1.0.0.0" - fi - - echo "Current version: $CURRENT_VERSION (from tag: $LATEST_TAG)" - echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + git config --local user.email "actions@github.com" + git config --local user.name "GitHub Actions" - - name: Calculate next version - id: next_version + - name: Determine bump + id: bump + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + DISPATCH_BUMP: ${{ github.event.inputs.version_bump }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} 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'" + set -o pipefail + if [ "$EVENT" = "workflow_dispatch" ]; then + BUMP="$DISPATCH_BUMP" + else + # A failing API call fails the job (no silent default). + PULLS=$(gh api "repos/$REPO/commits/$SHA/pulls") + LABELS=$(echo "$PULLS" \ + | jq -r --arg sha "$SHA" '[.[] | select(.merge_commit_sha == $sha)][0].labels // [] | .[].name' \ + | { grep -E '^release:(major|minor|patch|none)$' || true; } | sort -u) + echo "Merged PR: $(echo "$PULLS" | jq -r --arg sha "$SHA" '[.[] | select(.merge_commit_sha == $sha)][0].number // "none"')" + COUNT=$(printf '%s' "$LABELS" | grep -c . || true) + if [ "$COUNT" -gt 1 ]; then + echo "::error::The merged PR carries more than one release label: ${LABELS//$'\n'/ }" exit 1 fi - NEXT_VERSION="$CUSTOM_VERSION" + if [ "$COUNT" -eq 1 ]; then + BUMP="${LABELS#release:}" + else + BUMP="minor" + echo "No release label on the merged PR; default bump: minor" + fi + fi + 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 - 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" + echo "skip=false" >> "$GITHUB_OUTPUT" fi - echo "Next version: $NEXT_VERSION" - echo "next_version=$NEXT_VERSION" >> $GITHUB_OUTPUT - - - name: Create and push tag + - 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: | - 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" + if [ "$BUMP" = "custom" ]; then + ./release.sh --version "$CUSTOM_VERSION" --ci + else + ./release.sh "--$BUMP" --ci + fi - name: Create GitHub Release + if: steps.bump.outputs.skip != 'true' 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: ${{ steps.release.outputs.tag }} + name: "ModelingEvolution.Drawing v${{ steps.release.outputs.version }}" body: | - ## ModelingEvolution.Drawing v${{ steps.next_version.outputs.next_version }} + ## ModelingEvolution.Drawing v${{ steps.release.outputs.version }} ### Installation ```bash - dotnet add package ModelingEvolution.Drawing --version ${{ steps.next_version.outputs.next_version }} + dotnet add package ModelingEvolution.Drawing --version ${{ steps.release.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/${{ steps.release.outputs.previous }}...${{ steps.release.outputs.tag }} draft: false prerelease: false generateReleaseNotes: true - name: Summary + if: steps.bump.outputs.skip != 'true' + env: + BUMP: ${{ steps.bump.outputs.bump }} + PREVIOUS: ${{ steps.release.outputs.previous }} + VERSION: ${{ steps.release.outputs.version }} + TAG: ${{ steps.release.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 "## Release tagged" + echo "" + echo "- **Bump**: $BUMP" + echo "- **Previous Version**: $PREVIOUS" + echo "- **New Version**: $VERSION" + echo "- **Tag**: $TAG" + echo "" + echo "The publish job of this run builds, tests and pushes the package." + } >> "$GITHUB_STEP_SUMMARY" + + publish: + needs: tag + if: needs.tag.outputs.skip != 'true' + uses: ./.github/workflows/publish-nuget.yml + with: + version: ${{ needs.tag.outputs.version }} + secrets: inherit diff --git a/release.sh b/release.sh old mode 100644 new mode 100755 index 9c1574b..13c8268 --- a/release.sh +++ b/release.sh @@ -1,113 +1,120 @@ #!/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 +# --dry-run print the computed tag and exit 0 without tagging or pushing +# +# A non-preview release runs on master only. 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 -e +set -euo pipefail + +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\}//' +} - if [ -n "$latest_tag" ]; then - echo "${latest_tag#ModelingEvolution.Drawing/}" +# Highest release tag; -preview (and any other pre-release suffix) tags are ignored. +get_current_version() { + local latest + latest=$(git tag -l "$PACKAGE/*" | sed "s#^$PACKAGE/##" | grep -v -- '-' | sort -V | tail -1 || true) + 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 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"; 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,22 +123,35 @@ main() { exit 1 fi - # Check for uncommitted changes + if [ -n "$custom_version" ] && ! [[ "$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 + + git update-index -q --refresh || true 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 [ "$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) + 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 next_version="$custom_version" print_info "Using custom version: $next_version" @@ -140,38 +160,55 @@ 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" + git push origin "refs/tags/$tag_name" + + 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..cc679f4 --- /dev/null +++ b/release.test.sh @@ -0,0 +1,130 @@ +#!/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") + 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" + +# 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" +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 "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 + +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 + +# 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"; 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 ] From 33f9756c7220c8945e2154a4e2c2631d2dead472 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sat, 24 Oct 2026 12:30:19 +0000 Subject: [PATCH 2/6] epic-129 S1: review fixes on the release path - release.yml: no merged PR for the pushed commit -> retry 3 times (10 s), then fail; minor is the default only for a found PR without a label. - release.yml: concurrency group removed (it can cancel a pending release); the reasoning is in the header comment. - release.yml: the suite (restore, build, test Release) runs before release.sh, in a separate worktree because the tests rewrite tracked SVG galleries; the GitHub Release moves to a github-release job after publish. - release.yml: custom bump with an empty custom_version fails. - publish-nuget.yml: workflow_dispatch only re-publishes an existing release tag and builds that tag's commit. - release.sh: empty or malformed --version exits 1; tags are fetched from origin first (a failed fetch is a warning under --dry-run alone, exit 1 otherwise); untracked files make the tree dirty; a failed tag push removes the local tag. - release.test.sh: cases for the above; every invocation reads /dev/null. - release.ps1 deleted: a second release path with the old defects. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish-nuget.yml | 23 +++- .github/workflows/release.yml | 120 ++++++++++++----- release.ps1 | 201 ---------------------------- release.sh | 33 +++-- release.test.sh | 28 +++- 5 files changed, 156 insertions(+), 249 deletions(-) delete mode 100644 release.ps1 diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index aa8e07f..ad3b053 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -11,10 +11,11 @@ on: 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 @@ -28,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: @@ -65,6 +69,21 @@ jobs: 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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef369ca..9a6bda8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,14 @@ name: Release -# Merge to master: bump = the merged PR's label release:major|minor|patch|none (default minor). -# release.sh is the only version implementation; it tags and pushes. A tag pushed with GITHUB_TOKEN starts -# no other workflow, so this run calls publish-nuget.yml itself with the version release.sh produced. +# Merge to master: bump = the merged PR's label release:major|minor|patch|none (minor when the PR has no label; +# a push with no merged PR fails, releases come from labelled PRs). release.sh is the only version +# implementation; it tags and pushes. 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. +# +# No `concurrency` group on purpose: GitHub keeps one PENDING run per group and cancels it when a third run +# queues, so a release:major merge could be dropped without anyone noticing. Without it, two overlapping runs +# race loudly instead: release.sh refuses an existing tag and the publish push uses --skip-duplicate, so the +# loser fails and the lead re-runs it. on: push: @@ -27,10 +33,6 @@ permissions: contents: write pull-requests: read -concurrency: - group: release-master - cancel-in-progress: false - jobs: tag: runs-on: ubuntu-latest @@ -39,6 +41,7 @@ jobs: bump: ${{ steps.bump.outputs.bump }} version: ${{ steps.release.outputs.version }} tag: ${{ steps.release.outputs.tag }} + previous: ${{ steps.release.outputs.previous }} steps: - name: Checkout @@ -57,19 +60,36 @@ jobs: 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 - # A failing API call fails the job (no silent default). - PULLS=$(gh api "repos/$REPO/commits/$SHA/pulls") - LABELS=$(echo "$PULLS" \ - | jq -r --arg sha "$SHA" '[.[] | select(.merge_commit_sha == $sha)][0].labels // [] | .[].name' \ + # The commit -> PR association can lag right after a merge: retry. A failing API call fails the job. + PR="" + for attempt in 1 2 3 4; do + PR=$(gh api "repos/$REPO/commits/$SHA/pulls" \ + | jq -c --arg sha "$SHA" '[.[] | select(.merge_commit_sha == $sha)][0] // empty') + [ -n "$PR" ] && break + if [ "$attempt" -lt 4 ]; then + echo "No merged PR for $SHA yet (attempt $attempt); retrying in 10 s" + sleep 10 + fi + done + if [ -z "$PR" ]; 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 + echo "Merged PR: #$(echo "$PR" | jq -r '.number')" + LABELS=$(echo "$PR" | jq -r '.labels[].name' \ | { grep -E '^release:(major|minor|patch|none)$' || true; } | sort -u) - echo "Merged PR: $(echo "$PULLS" | jq -r --arg sha "$SHA" '[.[] | select(.merge_commit_sha == $sha)][0].number // "none"')" COUNT=$(printf '%s' "$LABELS" | grep -c . || true) if [ "$COUNT" -gt 1 ]; then echo "::error::The merged PR carries more than one release label: ${LABELS//$'\n'/ }" @@ -79,7 +99,7 @@ jobs: BUMP="${LABELS#release:}" else BUMP="minor" - echo "No release label on the merged PR; default bump: minor" + echo "The merged PR has no release label; default bump: minor" fi fi echo "Bump: $BUMP" @@ -91,6 +111,33 @@ jobs: echo "skip=false" >> "$GITHUB_OUTPUT" fi + # Nothing is tagged while the suite is red. The suite runs in a separate worktree of the same commit because + # it rewrites tracked files (the *-svg galleries at the repo root), and release.sh refuses a dirty tree. + - name: Setup .NET + if: steps.bump.outputs.skip != 'true' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Create the test worktree + if: steps.bump.outputs.skip != 'true' + run: git worktree add --detach "$RUNNER_TEMP/suite" HEAD + + - name: Restore + if: steps.bump.outputs.skip != 'true' + working-directory: ${{ runner.temp }}/suite/Sources + run: dotnet restore + + - name: Build + if: steps.bump.outputs.skip != 'true' + working-directory: ${{ runner.temp }}/suite/Sources + run: dotnet build --configuration Release --no-restore + + - name: Test + if: steps.bump.outputs.skip != 'true' + working-directory: ${{ runner.temp }}/suite/Sources + run: dotnet test --configuration Release --no-build --verbosity normal + - name: Tag with release.sh id: release if: steps.bump.outputs.skip != 'true' @@ -104,50 +151,53 @@ jobs: ./release.sh "--$BUMP" --ci fi + 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 - if: steps.bump.outputs.skip != 'true' uses: ncipollo/release-action@v1 with: - tag: ${{ steps.release.outputs.tag }} - name: "ModelingEvolution.Drawing v${{ steps.release.outputs.version }}" + tag: ${{ needs.tag.outputs.tag }} + name: "ModelingEvolution.Drawing v${{ needs.tag.outputs.version }}" body: | - ## ModelingEvolution.Drawing v${{ steps.release.outputs.version }} + ## ModelingEvolution.Drawing v${{ needs.tag.outputs.version }} ### Installation ```bash - dotnet add package ModelingEvolution.Drawing --version ${{ steps.release.outputs.version }} + dotnet add package ModelingEvolution.Drawing --version ${{ needs.tag.outputs.version }} ``` --- - **Full Changelog**: https://github.com/${{ github.repository }}/compare/ModelingEvolution.Drawing/${{ steps.release.outputs.previous }}...${{ steps.release.outputs.tag }} + **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 - if: steps.bump.outputs.skip != 'true' env: - BUMP: ${{ steps.bump.outputs.bump }} - PREVIOUS: ${{ steps.release.outputs.previous }} - VERSION: ${{ steps.release.outputs.version }} - TAG: ${{ steps.release.outputs.tag }} + BUMP: ${{ needs.tag.outputs.bump }} + PREVIOUS: ${{ needs.tag.outputs.previous }} + VERSION: ${{ needs.tag.outputs.version }} + TAG: ${{ needs.tag.outputs.tag }} run: | { - echo "## Release tagged" + echo "## Released" echo "" echo "- **Bump**: $BUMP" echo "- **Previous Version**: $PREVIOUS" echo "- **New Version**: $VERSION" echo "- **Tag**: $TAG" - echo "" - echo "The publish job of this run builds, tests and pushes the package." } >> "$GITHUB_STEP_SUMMARY" - - publish: - needs: tag - if: needs.tag.outputs.skip != 'true' - uses: ./.github/workflows/publish-nuget.yml - with: - version: ${{ needs.tag.outputs.version }} - secrets: inherit 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 index 13c8268..0b1baa9 100755 --- a/release.sh +++ b/release.sh @@ -10,10 +10,10 @@ # --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 +# --ci no prompts; refuses a dirty tree (untracked files count) # --dry-run print the computed tag and exit 0 without tagging or pushing # -# A non-preview release runs on master only. Inside GitHub Actions ($GITHUB_OUTPUT set) the script appends +# Tags are fetched from origin first. A non-preview release runs on master only. 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. @@ -98,6 +98,7 @@ monitor_url() { main() { local bump_type="patch" local custom_version="" + local version_given=false local preview=false local ci=false local dry_run=false @@ -109,7 +110,7 @@ main() { --major) bump_type="major"; shift ;; --version) if [[ $# -lt 2 ]]; then print_error "--version needs a value"; exit 1; fi - custom_version="$2"; shift 2 ;; + custom_version="$2"; version_given=true; shift 2 ;; --preview) preview=true; shift ;; --ci) ci=true; shift ;; --dry-run) dry_run=true; shift ;; @@ -123,7 +124,7 @@ main() { exit 1 fi - if [ -n "$custom_version" ] && ! [[ "$custom_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; 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 @@ -135,8 +136,7 @@ main() { exit 1 fi - git update-index -q --refresh || true - if ! git diff-index --quiet HEAD --; then + if [ -n "$(git status --porcelain)" ]; then if [ "$ci" = true ]; then print_error "Uncommitted changes; --ci refuses a dirty tree" exit 1 @@ -148,11 +148,23 @@ main() { fi fi + # 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 from origin (ignored by --dry-run); local tags may be stale" + else + print_error "Could not fetch tags from origin" + exit 1 + fi + fi + fi + local current_version next_version version tag_name current_version=$(get_current_version) print_info "Current version: $current_version" - if [ -n "$custom_version" ]; then + if [ "$version_given" = true ]; then next_version="$custom_version" print_info "Using custom version: $next_version" else @@ -194,7 +206,12 @@ main() { git tag -a "$tag_name" -m "Release $PACKAGE v$version" print_info "Pushing tag to origin..." - git push origin "refs/tags/$tag_name" + 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 { diff --git a/release.test.sh b/release.test.sh index cc679f4..120af6b 100755 --- a/release.test.sh +++ b/release.test.sh @@ -25,7 +25,7 @@ expect_tag() { local description=$1 expected="$PACKAGE/$2" shift 2 local actual status - actual=$("$SCRIPT" "$@" 2> "$WORK/stderr") + actual=$("$SCRIPT" "$@" 2> "$WORK/stderr" < /dev/null) status=$? if [ "$status" -eq 0 ] && [ "$actual" = "$expected" ]; then pass "$description -> $actual" @@ -61,6 +61,7 @@ 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") @@ -72,7 +73,7 @@ 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" +"$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 @@ -90,12 +91,17 @@ expect_tag "--dry-run without --ci needs no prompt" "1.14.0.72" --minor --dry-ru 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 @@ -103,10 +109,26 @@ 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 +# 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"; then +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" From 2283cf78fcefcbe9d4c4cfbf7aa1998af3078bc7 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sat, 24 Oct 2026 12:32:51 +0000 Subject: [PATCH 3/6] epic-129 S1: tests write their SVG galleries outside the repo BezierFitTests, CorrectionTests and DensifyTests wrote their galleries into the tracked bezier-fit-svg/, correction-svg/ and densify-svg/ folders, so a test run left a dirty tree (CRLF -> LF on Linux, and platform-dependent numbers). They now write to /ModelingEvolution.Drawing.Tests/; the committed galleries stay as a one-time snapshot. release.yml: the tag job runs the suite in its own checkout again (the worktree workaround is removed); release.sh --ci finds a clean tree. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 13 ++++--------- .../BezierFitTests.cs | 7 ++----- .../CorrectionTests.cs | 7 ++----- .../ModelingEvolution.Drawing.Tests/DensifyTests.cs | 7 ++----- 4 files changed, 10 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a6bda8..2b54085 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,31 +111,26 @@ jobs: echo "skip=false" >> "$GITHUB_OUTPUT" fi - # Nothing is tagged while the suite is red. The suite runs in a separate worktree of the same commit because - # it rewrites tracked files (the *-svg galleries at the repo root), and release.sh refuses a dirty tree. + # 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: Create the test worktree - if: steps.bump.outputs.skip != 'true' - run: git worktree add --detach "$RUNNER_TEMP/suite" HEAD - - name: Restore if: steps.bump.outputs.skip != 'true' - working-directory: ${{ runner.temp }}/suite/Sources + working-directory: ./Sources run: dotnet restore - name: Build if: steps.bump.outputs.skip != 'true' - working-directory: ${{ runner.temp }}/suite/Sources + working-directory: ./Sources run: dotnet build --configuration Release --no-restore - name: Test if: steps.bump.outputs.skip != 'true' - working-directory: ${{ runner.temp }}/suite/Sources + working-directory: ./Sources run: dotnet test --configuration Release --no-build --verbosity normal - name: Tag with release.sh 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)[] From 3ea65232c789893d9046f02091639ce54954d696 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sat, 24 Oct 2026 12:39:50 +0000 Subject: [PATCH 4/6] epic-129 S1: release bump over the whole range; never tag an older commit - release.yml: concurrency group release-master restored. The bump is the highest release:* label (major > minor > patch > none) over every PR merged in first-parent lastReleaseTag..HEAD, so a cancelled pending run loses nothing. An unlabelled PR counts as minor, all release:none skips, no PR for any commit fails. HEAD's lookup is retried (API lag after a merge). Recovery text: re-run only while the commit is master's tip. - release.sh: refuses to tag (previews included) when the newest release tag is not an ancestor of HEAD; skipped when there is no release tag yet. Fetch-failure message names both causes. - release.test.sh: ancestor cases (refused off-history, accepted after merge). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 98 ++++++++++++++++++++++++----------- release.sh | 23 ++++++-- release.test.sh | 15 ++++++ 3 files changed, 100 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b54085..988954f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,17 @@ name: Release -# Merge to master: bump = the merged PR's label release:major|minor|patch|none (minor when the PR has no label; -# a push with no merged PR fails, releases come from labelled PRs). release.sh is the only version -# implementation; it tags and pushes. A tag pushed with GITHUB_TOKEN starts no other workflow, so this run calls +# 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. # -# No `concurrency` group on purpose: GitHub keeps one PENDING run per group and cancels it when a third run -# queues, so a release:major merge could be dropped without anyone noticing. Without it, two overlapping runs -# race loudly instead: release.sh refuses an existing tag and the publish push uses --skip-duplicate, so the -# loser fails and the lead re-runs it. +# 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: @@ -33,6 +36,11 @@ 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: tag: runs-on: ubuntu-latest @@ -72,35 +80,63 @@ jobs: exit 1 fi else - # The commit -> PR association can lag right after a merge: retry. A failing API call fails the job. - PR="" - for attempt in 1 2 3 4; do - PR=$(gh api "repos/$REPO/commits/$SHA/pulls" \ - | jq -c --arg sha "$SHA" '[.[] | select(.merge_commit_sha == $sha)][0] // empty') - [ -n "$PR" ] && break - if [ "$attempt" -lt 4 ]; then - echo "No merged PR for $SHA yet (attempt $attempt); retrying in 10 s" - sleep 10 + 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 + 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 [ -z "$PR" ]; then + 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 - echo "Merged PR: #$(echo "$PR" | jq -r '.number')" - LABELS=$(echo "$PR" | jq -r '.labels[].name' \ - | { grep -E '^release:(major|minor|patch|none)$' || true; } | sort -u) - COUNT=$(printf '%s' "$LABELS" | grep -c . || true) - if [ "$COUNT" -gt 1 ]; then - echo "::error::The merged PR carries more than one release label: ${LABELS//$'\n'/ }" - exit 1 - fi - if [ "$COUNT" -eq 1 ]; then - BUMP="${LABELS#release:}" - else - BUMP="minor" - echo "The merged PR has no release label; default bump: minor" - fi fi echo "Bump: $BUMP" echo "bump=$BUMP" >> "$GITHUB_OUTPUT" diff --git a/release.sh b/release.sh index 0b1baa9..bed2c2b 100755 --- a/release.sh +++ b/release.sh @@ -13,7 +13,8 @@ # --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. Inside GitHub Actions ($GITHUB_OUTPUT set) the script appends +# 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. @@ -38,10 +39,14 @@ usage() { sed -n '6,14p' "$0" | sed 's/^# \{0,1\}//' } -# Highest release tag; -preview (and any other pre-release suffix) tags are ignored. +# 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 +} + get_current_version() { local latest - latest=$(git tag -l "$PACKAGE/*" | sed "s#^$PACKAGE/##" | grep -v -- '-' | sort -V | tail -1 || true) + latest=$(get_latest_release) if [ -n "$latest" ]; then echo "$latest" else @@ -152,14 +157,22 @@ main() { 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 from origin (ignored by --dry-run); local tags may be stale" + 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 from origin" + 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" diff --git a/release.test.sh b/release.test.sh index 120af6b..ee0f668 100755 --- a/release.test.sh +++ b/release.test.sh @@ -109,6 +109,21 @@ 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 \ From e7a3d4097dacb31e2b6413ae4333f83a843d480c Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sat, 24 Oct 2026 12:42:15 +0000 Subject: [PATCH 5/6] epic-129 S1: CI fails when tests write into the repository Both build-and-test jobs (x64 matrix and arm64) check git status --porcelain after the Test step. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b548b7..535c1f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,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 @@ -96,6 +99,9 @@ jobs: 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 From ec5f09bb916c1fbf41c2ce5c002d1c58020f4802 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sat, 24 Oct 2026 12:45:48 +0000 Subject: [PATCH 6/6] release.yml: HEAD without a merged PR fails the run; an older PR in the range never decides HEAD's bump Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 988954f..b889132 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,6 +112,11 @@ jobs: 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