Create Release and Publish to NuGet #46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Create Release and Publish to NuGet | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release_type: | |
| description: 'Release type' | |
| required: true | |
| default: 'patch' | |
| type: choice | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| force_release: | |
| description: 'Force release (bump every packable project even if it has no changes since the last tag)' | |
| required: false | |
| default: false | |
| type: boolean | |
| jobs: | |
| build-and-release: | |
| runs-on: ubuntu-latest | |
| steps: | |
| # Checkout repository with full history | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ssh-key: ${{ secrets.DEPLOY_KEY }} | |
| # Setup .NET SDK | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v3 | |
| with: | |
| dotnet-version: '10.0.x' | |
| # Check which projects changed and calculate their new versions | |
| - name: Check Changes and Calculate Versions | |
| id: version_check | |
| run: | | |
| SHOULD_RELEASE="false" | |
| CHANGED_PROJECTS="" | |
| CHANGED_PROJECT_PATHS="" | |
| LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") | |
| NEW_VERSION="" | |
| FORCE_RELEASE="${{ github.event.inputs.force_release }}" | |
| if [ "$FORCE_RELEASE" = "true" ]; then | |
| echo "Force release enabled: every packable project will be bumped regardless of git diff." | |
| fi | |
| # Function to compare version numbers | |
| version_gt() { | |
| test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1" | |
| } | |
| # Fetch the latest stable version of a package from NuGet. | |
| # Prints the version on stdout (empty on any failure) and a human- | |
| # readable explanation on stderr. Distinguishes between: | |
| # - package not yet published (HTTP 404) -> empty, log "new package" | |
| # - transient network / DNS / TLS failures -> empty, log warning | |
| # - HTTP 5xx or other unexpected status codes -> empty, log warning | |
| # - successful response but no stable versions -> empty, log warning | |
| # Always returns 0 so the workflow can fall back to the csproj version | |
| # rather than failing the release entirely. Pipefail-safe. | |
| fetch_latest_nuget_version() { | |
| local package_id="$1" | |
| if [ -z "$package_id" ]; then | |
| return 0 | |
| fi | |
| local lower | |
| lower=$(echo "$package_id" | tr '[:upper:]' '[:lower:]') | |
| local url="https://api.nuget.org/v3-flatcontainer/${lower}/index.json" | |
| # -sS: silent but show errors; -w appends HTTP status as the final line. | |
| # On total failure (DNS, TLS, timeout) curl exits non-zero and the | |
| # `|| true` keeps us going; we then inject "000" as the status code. | |
| local raw | |
| raw=$(curl -sS --retry 3 --retry-delay 2 --max-time 30 \ | |
| -w $'\n%{http_code}' "$url" 2>/dev/null || true) | |
| if [ -z "$raw" ]; then | |
| echo " -> network failure contacting NuGet for ${package_id}" >&2 | |
| return 0 | |
| fi | |
| local http_code | |
| http_code=$(printf '%s' "$raw" | tail -n 1) | |
| local body | |
| body=$(printf '%s' "$raw" | sed '$d') | |
| case "$http_code" in | |
| 200) | |
| : # fall through to parsing | |
| ;; | |
| 404) | |
| echo " -> ${package_id} is not yet published to NuGet (404, treating as new package)" >&2 | |
| return 0 | |
| ;; | |
| 000|"") | |
| echo " -> network failure contacting NuGet for ${package_id}" >&2 | |
| return 0 | |
| ;; | |
| *) | |
| echo " -> unexpected NuGet response for ${package_id} (HTTP ${http_code})" >&2 | |
| return 0 | |
| ;; | |
| esac | |
| if [ -z "$body" ]; then | |
| echo " -> empty NuGet response body for ${package_id}" >&2 | |
| return 0 | |
| fi | |
| # Extract stable MAJOR.MINOR.PATCH versions (skip prereleases). | |
| # `|| true` is required because grep returns 1 when there are no | |
| # matches and the surrounding step runs under `set -eo pipefail`. | |
| local versions | |
| versions=$(printf '%s' "$body" | grep -oE '"[0-9]+\.[0-9]+\.[0-9]+"' | tr -d '"' || true) | |
| if [ -z "$versions" ]; then | |
| echo " -> no stable versions found on NuGet for ${package_id}" >&2 | |
| return 0 | |
| fi | |
| printf '%s\n' "$versions" | sort -V | tail -n 1 | |
| } | |
| # Check each project for changes and update versions | |
| for proj in $(find . -name "*.csproj"); do | |
| PROJECT_NAME=$(basename "$proj") | |
| PROJECT_DIR=$(dirname "$proj") | |
| # Get current version from csproj | |
| CURRENT_VERSION=$(grep -oP '(?<=<Version>).*(?=</Version>)' "$proj" || echo "0.0.0") | |
| if [ -z "$CURRENT_VERSION" ]; then | |
| echo "Warning: Could not find version in $proj" | |
| continue | |
| fi | |
| # Resolve the package id used on NuGet. Falls back to the assembly | |
| # name (csproj filename without extension) when <PackageId> is omitted. | |
| PACKAGE_ID=$(grep -oP '(?<=<PackageId>).*(?=</PackageId>)' "$proj" | head -n 1 || echo "") | |
| if [ -z "$PACKAGE_ID" ]; then | |
| PACKAGE_ID="${PROJECT_NAME%.csproj}" | |
| fi | |
| # Reconcile against NuGet so a previously-published version is never | |
| # re-used (which would silently no-op behind --skip-duplicate). | |
| # The helper logs the specific reason on stderr if it returns empty. | |
| BASE_VERSION="$CURRENT_VERSION" | |
| LATEST_NUGET=$(fetch_latest_nuget_version "$PACKAGE_ID") | |
| if [ -n "$LATEST_NUGET" ]; then | |
| echo "Latest version of $PACKAGE_ID on NuGet: $LATEST_NUGET (csproj: $CURRENT_VERSION)" | |
| if version_gt "$LATEST_NUGET" "$BASE_VERSION"; then | |
| echo "Using NuGet version $LATEST_NUGET as bump baseline for $PACKAGE_ID" | |
| BASE_VERSION="$LATEST_NUGET" | |
| fi | |
| else | |
| echo "Falling back to csproj version $CURRENT_VERSION as bump baseline for $PACKAGE_ID" | |
| fi | |
| # Split version into parts | |
| IFS='.' read -r -a VERSION_PARTS <<< "$BASE_VERSION" | |
| MAJOR="${VERSION_PARTS[0]}" | |
| MINOR="${VERSION_PARTS[1]}" | |
| PATCH="${VERSION_PARTS[2]}" | |
| # Check if project has changes since last tag (or force release) | |
| if [ "$FORCE_RELEASE" = "true" ] || [ -z "$LATEST_TAG" ] || ! git diff --quiet $LATEST_TAG HEAD -- "$PROJECT_DIR"; then | |
| # Calculate new version based on release type | |
| case "${{ github.event.inputs.release_type }}" in | |
| "major") | |
| PROJECT_NEW_VERSION="$((MAJOR + 1)).0.0" | |
| ;; | |
| "minor") | |
| PROJECT_NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" | |
| ;; | |
| "patch") | |
| PROJECT_NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" | |
| ;; | |
| esac | |
| # Update version in csproj | |
| sed -i "s/<Version>.*<\/Version>/<Version>${PROJECT_NEW_VERSION}<\/Version>/g" "$proj" | |
| CHANGED_PROJECTS="$CHANGED_PROJECTS$PROJECT_NAME -> $PROJECT_NEW_VERSION\n" | |
| CHANGED_PROJECT_PATHS="$CHANGED_PROJECT_PATHS$proj\n" | |
| SHOULD_RELEASE="true" | |
| # Update NEW_VERSION if this project's version is higher | |
| if [ -z "$NEW_VERSION" ] || version_gt "$PROJECT_NEW_VERSION" "$NEW_VERSION"; then | |
| NEW_VERSION="$PROJECT_NEW_VERSION" | |
| echo "New highest version: $NEW_VERSION from $PROJECT_NAME" | |
| fi | |
| fi | |
| done | |
| if [ -z "$NEW_VERSION" ]; then | |
| if [ "$FORCE_RELEASE" = "true" ]; then | |
| echo "::error::Force release was requested but no packable project with a <Version> was found." | |
| exit 1 | |
| fi | |
| echo "No version changes detected" | |
| exit 0 | |
| fi | |
| echo "Final highest version will be: v$NEW_VERSION" | |
| # Set environment variables for next steps | |
| echo "CHANGED_PROJECTS<<EOF" >> $GITHUB_ENV | |
| echo -e "$CHANGED_PROJECTS" >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| echo "CHANGED_PROJECT_PATHS<<EOF" >> $GITHUB_ENV | |
| echo -e "$CHANGED_PROJECT_PATHS" >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| echo "SHOULD_RELEASE=$SHOULD_RELEASE" >> $GITHUB_ENV | |
| echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV | |
| # Generate fallback release notes and collect context for optional AI summarization. | |
| - name: Generate Release Notes Context | |
| if: env.SHOULD_RELEASE == 'true' | |
| id: release_notes | |
| run: | | |
| PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") | |
| if [ -z "$PREVIOUS_TAG" ]; then | |
| COMMITS=$(git log --pretty=format:"- %s" --no-merges) | |
| FILE_STATS=$(git diff --stat --find-renames HEAD~1 HEAD 2>/dev/null || echo "No diff stat available.") | |
| CHANGED_FILES=$(git diff --name-only --find-renames HEAD~1 HEAD 2>/dev/null || echo "No changed files available.") | |
| else | |
| COMMITS=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s" --no-merges) | |
| FILE_STATS=$(git diff --stat --find-renames ${PREVIOUS_TAG}..HEAD) | |
| CHANGED_FILES=$(git diff --name-only --find-renames ${PREVIOUS_TAG}..HEAD) | |
| fi | |
| echo "RELEASE_NOTES_FALLBACK<<EOF" >> $GITHUB_ENV | |
| echo "## What's Changed" >> $GITHUB_ENV | |
| echo "$COMMITS" >> $GITHUB_ENV | |
| echo -e "\n## Updated Projects" >> $GITHUB_ENV | |
| echo "${{ env.CHANGED_PROJECTS }}" >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV | |
| echo "## What's Changed" >> $GITHUB_ENV | |
| echo "$COMMITS" >> $GITHUB_ENV | |
| echo -e "\n## Updated Projects" >> $GITHUB_ENV | |
| echo "${{ env.CHANGED_PROJECTS }}" >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| echo "RELEASE_CONTEXT<<EOF" >> $GITHUB_ENV | |
| echo "Release type: ${{ github.event.inputs.release_type }}" >> $GITHUB_ENV | |
| echo "Target version: v${{ env.NEW_VERSION }}" >> $GITHUB_ENV | |
| echo "Previous tag: ${PREVIOUS_TAG:-none}" >> $GITHUB_ENV | |
| echo >> $GITHUB_ENV | |
| echo "Updated projects:" >> $GITHUB_ENV | |
| echo "${{ env.CHANGED_PROJECTS }}" >> $GITHUB_ENV | |
| echo >> $GITHUB_ENV | |
| echo "Commits:" >> $GITHUB_ENV | |
| echo "$COMMITS" >> $GITHUB_ENV | |
| echo >> $GITHUB_ENV | |
| echo "Changed files:" >> $GITHUB_ENV | |
| echo "$CHANGED_FILES" >> $GITHUB_ENV | |
| echo >> $GITHUB_ENV | |
| echo "Diff stat:" >> $GITHUB_ENV | |
| echo "$FILE_STATS" >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| - name: Generate AI Release Notes | |
| if: env.SHOULD_RELEASE == 'true' | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| run: | | |
| python <<'PY' | |
| import json | |
| import os | |
| import sys | |
| import urllib.request | |
| github_env = os.environ["GITHUB_ENV"] | |
| openai_key = os.environ.get("OPENAI_API_KEY", "").strip() | |
| anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() | |
| fallback = os.environ.get("RELEASE_NOTES_FALLBACK", "").strip() | |
| context = os.environ.get("RELEASE_CONTEXT", "").strip() | |
| if openai_key: | |
| print(f"::add-mask::{openai_key}") | |
| if anthropic_key: | |
| print(f"::add-mask::{anthropic_key}") | |
| prompt = f""" | |
| Write release notes in polished, concise markdown for an open source .NET library release. | |
| Group the notes into these sections when relevant: | |
| - ## Features | |
| - ## Fixes | |
| - ## Chores | |
| Requirements: | |
| - Keep it concise and useful. | |
| - Use flat bullet lists only. | |
| - Omit empty sections. | |
| - Focus on user-visible value and meaningful maintenance work. | |
| - Do not mention version-bump automation, CI internals, or speculate. | |
| - Return markdown only, with no preamble or code fences. | |
| Release context: | |
| {context} | |
| """.strip() | |
| def write_env(key: str, value: str) -> None: | |
| with open(github_env, "a", encoding="utf-8") as fh: | |
| fh.write(f"{key}<<EOF\n{value}\nEOF\n") | |
| def call_anthropic() -> str: | |
| payload = { | |
| "model": "claude-sonnet-4-6", | |
| "max_tokens": 500, | |
| "messages": [{"role": "user", "content": prompt}], | |
| } | |
| req = urllib.request.Request( | |
| "https://api.anthropic.com/v1/messages", | |
| data=json.dumps(payload).encode("utf-8"), | |
| headers={ | |
| "x-api-key": anthropic_key, | |
| "anthropic-version": "2023-06-01", | |
| "content-type": "application/json", | |
| }, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(req, timeout=60) as resp: | |
| data = json.loads(resp.read().decode("utf-8")) | |
| content = data.get("content", []) | |
| parts = [item.get("text", "") for item in content if item.get("type") == "text"] | |
| return "\n".join(part for part in parts if part).strip() | |
| def call_openai() -> str: | |
| payload = { | |
| "model": "gpt-5.4-2026-03-05", | |
| "messages": [{"role": "user", "content": prompt}], | |
| "temperature": 0.2, | |
| } | |
| req = urllib.request.Request( | |
| "https://api.openai.com/v1/chat/completions", | |
| data=json.dumps(payload).encode("utf-8"), | |
| headers={ | |
| "Authorization": f"Bearer {openai_key}", | |
| "Content-Type": "application/json", | |
| }, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(req, timeout=60) as resp: | |
| data = json.loads(resp.read().decode("utf-8")) | |
| return data["choices"][0]["message"]["content"].strip() | |
| try: | |
| notes = "" | |
| provider = "none" | |
| if anthropic_key: | |
| provider = "anthropic" | |
| notes = call_anthropic() | |
| elif openai_key: | |
| provider = "openai" | |
| notes = call_openai() | |
| if not notes: | |
| print("AI release notes unavailable; using fallback notes.") | |
| if not fallback: | |
| sys.exit(0) | |
| write_env("RELEASE_NOTES", fallback) | |
| with open(github_env, "a", encoding="utf-8") as fh: | |
| fh.write("AI_RELEASE_NOTES_PROVIDER=none\n") | |
| sys.exit(0) | |
| write_env("RELEASE_NOTES", notes) | |
| with open(github_env, "a", encoding="utf-8") as fh: | |
| fh.write(f"AI_RELEASE_NOTES_PROVIDER={provider}\n") | |
| print(f"Generated AI release notes via {provider}.") | |
| except Exception: | |
| print("AI release notes generation failed; using fallback notes.") | |
| if fallback: | |
| write_env("RELEASE_NOTES", fallback) | |
| with open(github_env, "a", encoding="utf-8") as fh: | |
| fh.write("AI_RELEASE_NOTES_PROVIDER=fallback\n") | |
| PY | |
| # Restore project dependencies | |
| - name: Restore dependencies | |
| if: env.SHOULD_RELEASE == 'true' | |
| working-directory: src | |
| run: dotnet restore FastCloner.slnx | |
| # Build solution | |
| - name: Build | |
| if: env.SHOULD_RELEASE == 'true' | |
| working-directory: src | |
| run: dotnet build FastCloner.slnx --configuration Release --no-restore | |
| # Create NuGet packages | |
| - name: Pack | |
| if: env.SHOULD_RELEASE == 'true' | |
| working-directory: src | |
| run: dotnet pack FastCloner.slnx --configuration Release --no-build --output ../nupkgs | |
| # Commit version updates locally and create tag. | |
| # Remote GitHub state is updated only after NuGet publish succeeds. | |
| - name: Commit version updates and create tag | |
| if: env.SHOULD_RELEASE == 'true' && env.NEW_VERSION != '' | |
| run: | | |
| if [ -z "${{ env.NEW_VERSION }}" ]; then | |
| echo "Error: NEW_VERSION is not set" | |
| exit 1 | |
| fi | |
| echo "Creating release for version v${{ env.NEW_VERSION }}" | |
| git config --local user.email "action@github.com" | |
| git config --local user.name "GitHub Action" | |
| while IFS= read -r proj; do | |
| if [ -n "$proj" ]; then | |
| git add "$proj" | |
| fi | |
| done <<< "${{ env.CHANGED_PROJECT_PATHS }}" | |
| git commit -m "Update project versions to v${{ env.NEW_VERSION }}" | |
| git tag -a "v${{ env.NEW_VERSION }}" -m "Release v${{ env.NEW_VERSION }}" | |
| # Publish packages to NuGet.org before pushing any GitHub-visible release state. | |
| - name: Push to NuGet | |
| if: env.SHOULD_RELEASE == 'true' | |
| run: dotnet nuget push "./nupkgs/*.nupkg" --source "https://api.nuget.org/v3/index.json" --api-key ${{secrets.NUGET_API_KEY}} --skip-duplicate | |
| - name: Push release commit and tag | |
| if: env.SHOULD_RELEASE == 'true' && env.NEW_VERSION != '' | |
| run: git push --follow-tags | |
| # Create GitHub release only after commit/tag and NuGet publish have succeeded. | |
| - name: Create Release | |
| if: env.SHOULD_RELEASE == 'true' | |
| id: create_release | |
| uses: actions/create-release@v1 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| with: | |
| tag_name: v${{ env.NEW_VERSION }} | |
| release_name: Release v${{ env.NEW_VERSION }} | |
| body: ${{ env.RELEASE_NOTES }} | |
| draft: false | |
| prerelease: false | |
| # Upload NuGet packages as release assets | |
| - name: Upload Release Assets | |
| if: env.SHOULD_RELEASE == 'true' | |
| uses: softprops/action-gh-release@v1 | |
| with: | |
| files: ./nupkgs/*.nupkg | |
| tag_name: v${{ env.NEW_VERSION }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |