Skip to content

Create Release and Publish to NuGet #46

Create Release and Publish to NuGet

Create Release and Publish to NuGet #46

Workflow file for this run

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 }}