Skip to content

fix(generator): use CustomDeserializer instead of CustomSerializer fo… #38

fix(generator): use CustomDeserializer instead of CustomSerializer fo…

fix(generator): use CustomDeserializer instead of CustomSerializer fo… #38

Workflow file for this run

name: Release
# IMPORTANT: This workflow ensures the release tag points to the Unity DLL commit
# for proper UPM (Unity Package Manager) functionality. The workflow:
# 1. Updates version files and commits to main
# 2. Builds and copies Unity DLLs, commits to main
# 3. Moves the release tag to the Unity DLL commit (CRITICAL for UPM)
# This ensures UPM gets the correct DLLs when fetching the tagged release.
on:
push:
tags:
- 'v*' # Trigger on version tags like v1.2.3, v1.2.3-beta.1, etc.
# Security: Minimal required permissions
permissions:
contents: write # Create releases and push commits
actions: write # Upload artifacts (needed by called CI workflow)
packages: write # Publish to GitHub Packages (if needed)
pull-requests: read # Read PR info for context
checks: write # Write test results (needed by called CI workflow)
# Prevent concurrent releases
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
validate-tag:
name: Validate Release Tag
runs-on: ubuntu-latest
outputs:
version: ${{ steps.parse.outputs.version }}
is_prerelease: ${{ steps.parse.outputs.is_prerelease }}
release_type: ${{ steps.parse.outputs.release_type }}
full_version: ${{ steps.parse.outputs.full_version }}
steps:
- name: Parse version from tag
id: parse
run: |
set -euo pipefail # Exit on error, undefined vars, pipe failures
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "Processing tag: $TAG_NAME"
# Security: Validate tag name doesn't contain dangerous characters
if [[ $TAG_NAME =~ [\$\`\;\|\&] ]]; then
echo "❌ Security: Tag contains dangerous characters: $TAG_NAME"
exit 1
fi
# Validate semantic version format (v1.2.3, v1.2.3-beta.1, v1.2.3-alpha.1, etc.)
if [[ $TAG_NAME =~ ^v([0-9]+\.[0-9]+\.[0-9]+)(-([a-zA-Z]+)\.([0-9]+))?$ ]]; then
VERSION="${BASH_REMATCH[1]}"
PRERELEASE_LABEL="${BASH_REMATCH[3]}"
PRERELEASE_NUM="${BASH_REMATCH[4]}"
# Additional validation: Version components should be reasonable
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
if [[ $MAJOR -gt 999 || $MINOR -gt 999 || $PATCH -gt 999 ]]; then
echo "❌ Version components too large: $VERSION"
exit 1
fi
if [[ -n "$PRERELEASE_LABEL" ]]; then
# Validate prerelease label
if [[ ! $PRERELEASE_LABEL =~ ^(alpha|beta|rc)$ ]]; then
echo "❌ Invalid prerelease label: $PRERELEASE_LABEL"
echo "Allowed: alpha, beta, rc"
exit 1
fi
IS_PRERELEASE=true
RELEASE_TYPE="$PRERELEASE_LABEL"
FULL_VERSION="$VERSION-$PRERELEASE_LABEL.$PRERELEASE_NUM"
else
IS_PRERELEASE=false
RELEASE_TYPE="stable"
FULL_VERSION="$VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
echo "release_type=$RELEASE_TYPE" >> $GITHUB_OUTPUT
echo "full_version=$FULL_VERSION" >> $GITHUB_OUTPUT
echo "✅ Valid version tag: $FULL_VERSION (prerelease: $IS_PRERELEASE)"
else
echo "❌ Invalid tag format: $TAG_NAME"
echo "Expected format: v1.2.3 or v1.2.3-beta.1"
exit 1
fi
verify-ci-status:
name: Verify CI Status
runs-on: ubuntu-latest
needs: [validate-tag]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check CI status for commit
run: |
set -euo pipefail
# Get the commit SHA that the tag points to
COMMIT_SHA=$(git rev-list -n 1 ${{ github.ref }})
echo "Checking CI status for commit: $COMMIT_SHA"
# Wait for CI to complete with timeout
max_wait_minutes=30
wait_interval=30
elapsed=0
last_status=""
while [ $elapsed -lt $((max_wait_minutes * 60)) ]; do
# Get CI runs for this commit (most recent first)
CI_RUNS=$(gh run list \
--workflow="CI - Build and Test" \
--limit=100 \
--json="headSha,conclusion,status,event" \
--jq="[.[] | select(.headSha == \"$COMMIT_SHA\")] | sort_by(.createdAt) | reverse")
if [[ "$CI_RUNS" == "[]" || -z "$CI_RUNS" ]]; then
echo "⚠️ No CI run found for commit $COMMIT_SHA"
echo "This might be expected for the first commit or if CI was added later"
echo "Proceeding with release but consider running CI manually"
break
fi
# Get the most relevant CI run (prefer push events over pull_request)
CI_INFO=$(echo "$CI_RUNS" | jq -r '
(.[] | select(.event == "push")) //
(.[] | select(.event == "pull_request")) //
.[0]')
if [[ "$CI_INFO" == "null" || -z "$CI_INFO" ]]; then
echo "❌ Could not parse CI run information"
exit 1
fi
CI_STATUS=$(echo "$CI_INFO" | jq -r '.status // "unknown"')
CI_CONCLUSION=$(echo "$CI_INFO" | jq -r '.conclusion // "none"')
CI_EVENT=$(echo "$CI_INFO" | jq -r '.event // "unknown"')
# Only log status changes to reduce noise
current_status="$CI_STATUS:$CI_CONCLUSION"
if [[ "$current_status" != "$last_status" ]]; then
echo "CI Status: $CI_STATUS, Conclusion: $CI_CONCLUSION, Event: $CI_EVENT"
last_status="$current_status"
fi
case "$CI_STATUS" in
"completed")
case "$CI_CONCLUSION" in
"success")
echo "✅ CI passed for commit $COMMIT_SHA"
exit 0
;;
"failure"|"cancelled"|"timed_out")
echo "❌ CI failed with conclusion: $CI_CONCLUSION for commit $COMMIT_SHA"
echo "Cannot release a commit with failed CI"
exit 1
;;
"skipped")
echo "⚠️ CI was skipped for commit $COMMIT_SHA"
echo "This may be intentional, proceeding with release"
exit 0
;;
*)
echo "❌ CI completed with unexpected conclusion: $CI_CONCLUSION for commit $COMMIT_SHA"
echo "Only 'success' or 'skipped' conclusions allow release"
exit 1
;;
esac
;;
"in_progress"|"queued"|"pending"|"waiting")
if [[ "$current_status" != "$last_status" ]]; then
echo "⏳ CI is running (status: $CI_STATUS). Will check again in ${wait_interval}s..."
fi
sleep $wait_interval
elapsed=$((elapsed + wait_interval))
;;
*)
echo "❌ Unexpected CI status: $CI_STATUS for commit $COMMIT_SHA"
exit 1
;;
esac
done
# Check if we timed out
if [ $elapsed -ge $((max_wait_minutes * 60)) ]; then
echo "⏰ Timeout: CI did not complete within $max_wait_minutes minutes"
echo "Last known status: $CI_STATUS, conclusion: $CI_CONCLUSION"
echo "Please wait for CI to complete and try the release again"
exit 1
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-versions:
name: Update Version Files
runs-on: ubuntu-latest
needs: [validate-tag, verify-ci-status]
outputs:
commit_sha: ${{ steps.commit.outputs.commit_sha }}
steps:
- name: Checkout code at tagged commit
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
persist-credentials: true
- name: Setup git for pushing to main
run: |
# Get the tag commit and ensure we can push to main
TAG_COMMIT=$(git rev-parse HEAD)
echo "Working on tagged commit: $TAG_COMMIT"
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Create a branch from this commit to push changes
git checkout -b temp-release-branch
echo "✅ Ready to make version updates from tagged commit"
- name: Update version files
run: |
VERSION="${{ needs.validate-tag.outputs.version }}"
FULL_VERSION="${{ needs.validate-tag.outputs.full_version }}"
IS_PRERELEASE="${{ needs.validate-tag.outputs.is_prerelease }}"
echo "Updating files to version: $VERSION (full: $FULL_VERSION, prerelease: $IS_PRERELEASE)"
# Update Version.cs (always use base version for assemblies)
sed -i "s/AssemblyVersion(\"[^\"]*\")/AssemblyVersion(\"$VERSION\")/" src/Version.cs
sed -i "s/AssemblyFileVersion(\"[^\"]*\")/AssemblyFileVersion(\"$VERSION\")/" src/Version.cs
# Update .csproj files (use full version with pre-release suffix for NuGet)
for proj in src/Nino/Nino.csproj src/Nino.Core/Nino.Core.csproj src/Nino.Generator/Nino.Generator.csproj; do
if [[ "$IS_PRERELEASE" == "true" ]]; then
sed -i "s/<Version>[^<]*<\/Version>/<Version>$FULL_VERSION<\/Version>/" "$proj"
echo "Updated $proj to pre-release version: $FULL_VERSION"
else
sed -i "s/<Version>[^<]*<\/Version>/<Version>$VERSION<\/Version>/" "$proj"
echo "Updated $proj to stable version: $VERSION"
fi
done
# Unity UPM handling: Unity doesn't support semantic pre-release format well
# For pre-releases, we use an offset system to ensure proper version ordering
if [[ "$IS_PRERELEASE" == "true" ]]; then
RELEASE_TYPE="${{ needs.validate-tag.outputs.release_type }}"
# Extract pre-release number from the full version (e.g., alpha.1, beta.2, rc.3)
PRERELEASE_NUM=1
if [[ "$FULL_VERSION" =~ -[a-zA-Z]+\.([0-9]+)$ ]]; then
PRERELEASE_NUM="${BASH_REMATCH[1]}"
fi
# Calculate preview number with offsets:
# alpha: 0-99 (offset 0)
# beta: 100-199 (offset 100)
# rc: 200-299 (offset 200)
case "$RELEASE_TYPE" in
"alpha")
PREVIEW_NUM=$((PRERELEASE_NUM))
echo "Alpha release: using preview number $PREVIEW_NUM (0-99 range)"
;;
"beta")
PREVIEW_NUM=$((100 + PRERELEASE_NUM))
echo "Beta release: using preview number $PREVIEW_NUM (100-199 range)"
;;
"rc")
PREVIEW_NUM=$((200 + PRERELEASE_NUM))
echo "RC release: using preview number $PREVIEW_NUM (200-299 range)"
;;
*)
echo "Unknown release type: $RELEASE_TYPE, defaulting to preview number 1"
PREVIEW_NUM=1
;;
esac
# Ensure we don't exceed the range (max 99 versions per type)
if [[ $PRERELEASE_NUM -gt 99 ]]; then
echo "Warning: Pre-release number $PRERELEASE_NUM exceeds 99, capping at 99"
case "$RELEASE_TYPE" in
"alpha") PREVIEW_NUM=99 ;;
"beta") PREVIEW_NUM=199 ;;
"rc") PREVIEW_NUM=299 ;;
esac
fi
UNITY_VERSION="$VERSION-preview.$PREVIEW_NUM"
sed -i "s/\"version\": \"[^\"]*\",/\"version\": \"$UNITY_VERSION\",/" src/Nino.Unity/Packages/com.jasonxudeveloper.nino/package.json
echo "Updated Unity package.json to preview version: $UNITY_VERSION"
else
sed -i "s/\"version\": \"[^\"]*\",/\"version\": \"$VERSION\",/" src/Nino.Unity/Packages/com.jasonxudeveloper.nino/package.json
echo "Updated Unity package.json to stable version: $VERSION"
fi
echo "✅ All version files updated"
- name: Commit version updates
id: commit
run: |
VERSION="${{ needs.validate-tag.outputs.version }}"
# Check if there are changes
if [[ -n "$(git status --porcelain)" ]]; then
git add .
git commit -m "Bump version to v$VERSION"
# Push the changes to main branch
max_attempts=3
attempt=1
delay=5
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts: Pushing version bump commit to main..."
if git push origin temp-release-branch:main; then
echo "✅ Successfully pushed version bump to main"
break
fi
if [ $attempt -lt $max_attempts ]; then
echo "⚠️ Push failed, retrying in ${delay}s..."
sleep $delay
delay=$((delay * 2))
else
echo "❌ Failed to push after $max_attempts attempts"
exit 1
fi
attempt=$((attempt + 1))
done
COMMIT_SHA=$(git rev-parse HEAD)
echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
echo "✅ Version files updated and committed"
else
echo "No version changes detected"
COMMIT_SHA=$(git rev-parse HEAD)
echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
fi
build-release:
name: Build Release Artifacts
runs-on: ubuntu-latest
needs: [validate-tag, verify-ci-status, update-versions]
defaults:
run:
working-directory: ./src
steps:
- name: Checkout updated code
uses: actions/checkout@v4
with:
ref: ${{ needs.update-versions.outputs.commit_sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
persist-credentials: true
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
${{ vars.DOTNET_VERSION || '8.0.x' }}
8.0.x
6.0.x
2.1.x
- name: Restore dependencies
run: dotnet restore
- name: Build Release
run: dotnet build --configuration Release --no-restore
- name: Copy Release DLLs to Unity
run: |
cp ./Nino.Core/bin/Release/netstandard2.1/Nino.Core.dll ./Nino.Unity/Packages/com.jasonxudeveloper.nino/Runtime/Nino.Core.dll
cp ./Nino/bin/Release/netstandard2.1/Nino.Generator.dll ./Nino.Unity/Packages/com.jasonxudeveloper.nino/Runtime/Nino.Generator.dll
- name: Commit Unity DLL updates
run: |
VERSION="${{ needs.validate-tag.outputs.version }}"
FULL_VERSION="${{ needs.validate-tag.outputs.full_version }}"
IS_PRERELEASE="${{ needs.validate-tag.outputs.is_prerelease }}"
TAG_NAME=${GITHUB_REF#refs/tags/}
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if [[ -n "$(git status --porcelain)" ]]; then
git add ./Nino.Unity/Packages/com.jasonxudeveloper.nino/Runtime/*.dll
# Always commit Unity DLL updates to main branch regardless of release type
CURRENT_COMMIT=$(git rev-parse HEAD)
echo "Current commit: $CURRENT_COMMIT"
# Create a temporary branch from current commit
git checkout -b temp-unity-dll-update
# Use appropriate commit message based on release type
if [[ "$IS_PRERELEASE" == "true" ]]; then
git commit -m "Update Unity Package DLLs to $FULL_VERSION (pre-release)"
echo "📦 Committing Unity DLLs for pre-release $FULL_VERSION to main branch"
else
git commit -m "Update Unity Package DLLs to v$VERSION"
echo "📦 Committing Unity DLLs for stable release v$VERSION to main branch"
fi
# Push Unity DLL updates to main branch with retry logic
max_attempts=3
attempt=1
delay=5
push_success=false
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts: Pushing Unity DLL updates to main..."
if git push origin temp-unity-dll-update:main; then
echo "✅ Unity DLLs updated on main branch"
push_success=true
break
fi
if [ $attempt -lt $max_attempts ]; then
echo "⚠️ Push failed, retrying in ${delay}s..."
sleep $delay
delay=$((delay * 2))
else
echo "❌ Failed to push Unity DLLs after $max_attempts attempts"
fi
attempt=$((attempt + 1))
done
# Move the tag to point to the Unity DLL commit (CRITICAL for UPM)
if [ "$push_success" = true ]; then
echo "🏷️ Moving tag $TAG_NAME to Unity DLL commit for UPM compatibility"
# Fetch the latest main to get the Unity DLL commit
git fetch origin main
UNITY_DLL_COMMIT=$(git rev-parse origin/main)
echo "Unity DLL commit SHA: $UNITY_DLL_COMMIT"
# Delete and recreate the tag at the Unity DLL commit
git tag -d "$TAG_NAME"
git tag -a "$TAG_NAME" -m "Release $TAG_NAME" "$UNITY_DLL_COMMIT"
# Push the updated tag
tag_push_attempts=3
tag_attempt=1
tag_delay=5
while [ $tag_attempt -le $tag_push_attempts ]; do
echo "Attempt $tag_attempt/$tag_push_attempts: Moving tag $TAG_NAME to Unity DLL commit..."
if git push origin "$TAG_NAME" --force; then
echo "✅ Successfully moved tag $TAG_NAME to Unity DLL commit $UNITY_DLL_COMMIT"
echo "🎯 Tag now points to commit with Unity DLLs for UPM compatibility"
break
fi
if [ $tag_attempt -lt $tag_push_attempts ]; then
echo "⚠️ Tag push failed, retrying in ${tag_delay}s..."
sleep $tag_delay
tag_delay=$((tag_delay * 2))
else
echo "❌ Failed to move tag after $tag_push_attempts attempts"
echo "⚠️ Tag still points to version commit, not Unity DLL commit"
fi
tag_attempt=$((tag_attempt + 1))
done
fi
# Always clean up the temporary branch (whether push succeeded or failed)
echo "🧹 Cleaning up temporary branch..."
if git push origin --delete temp-unity-dll-update 2>/dev/null; then
echo "✅ Temporary branch deleted successfully"
else
echo "ℹ️ Temporary branch cleanup skipped (may not exist on remote)"
fi
# Exit with error if push ultimately failed
if [ "$push_success" = false ]; then
echo "❌ Unity DLL push failed after cleanup"
exit 1
fi
else
echo "No Unity DLL changes detected"
fi
- name: Create NuGet packages
run: |
dotnet pack Nino.Core/Nino.Core.csproj -c Release --no-build
dotnet pack Nino.Generator/Nino.Generator.csproj -c Release --no-build
dotnet pack Nino/Nino.csproj -c Release --no-build
- name: Upload NuGet packages
uses: actions/upload-artifact@v4
with:
name: nuget-packages
path: |
src/Nino.Core/bin/Release/*.nupkg
src/Nino.Generator/bin/Release/*.nupkg
src/Nino/bin/Release/*.nupkg
retention-days: 30
create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [validate-tag, build-release, update-versions]
outputs:
release_url: ${{ steps.create_release.outputs.html_url }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.update-versions.outputs.commit_sha }}
fetch-depth: 0
- name: Generate release notes
id: release_notes
run: |
set -euo pipefail
TAG_NAME=${GITHUB_REF#refs/tags/}
CURRENT_TYPE="${{ needs.validate-tag.outputs.release_type }}"
CURRENT_IS_PRERELEASE="${{ needs.validate-tag.outputs.is_prerelease }}"
# Smart previous tag selection based on release type transitions
echo "🔍 Determining appropriate comparison tag for $TAG_NAME (type: $CURRENT_TYPE)"
# Parse current version components
if [[ $TAG_NAME =~ ^v([0-9]+\.[0-9]+\.[0-9]+)(-([a-zA-Z]+)\.([0-9]+))?$ ]]; then
CURRENT_VERSION="${BASH_REMATCH[1]}"
CURRENT_PRERELEASE_LABEL="${BASH_REMATCH[3]}"
CURRENT_PRERELEASE_NUM="${BASH_REMATCH[4]}"
else
echo "❌ Could not parse current tag format"
exit 1
fi
# Get all successful releases (not just tags) to find the right comparison point
echo "🔍 Fetching successful releases from GitHub API..."
ALL_RELEASES=$(gh release list --limit 100 --json tagName,isPrerelease,isDraft --jq '[.[] | select(.isDraft == false)] | sort_by(.tagName) | reverse | .[].tagName')
# Also get all tags for fallback
ALL_TAGS=$(git tag -l "v*" --sort=-version:refname)
# Function to find last successful release of a given type
find_last_successful_release() {
local pattern="$1"
local exclude_current="$2"
for tag in $ALL_RELEASES; do
if [[ "$exclude_current" == "true" && "$tag" == "$TAG_NAME" ]]; then
continue
fi
if [[ $tag =~ $pattern ]]; then
echo "$tag"
return 0
fi
done
# Fallback to tags if no release found
echo "$ALL_TAGS" | grep -E "$pattern" | head -n1
}
# Find appropriate comparison tag based on transition logic
PREVIOUS_TAG=""
if [[ "$CURRENT_IS_PRERELEASE" == "true" ]]; then
# Current is pre-release (alpha/beta/rc)
case "$CURRENT_TYPE" in
"alpha")
# alpha.X: Compare to last successful alpha release or last stable release
LAST_ALPHA=$(find_last_successful_release "^v$CURRENT_VERSION-alpha\." true)
LAST_STABLE=$(find_last_successful_release "^v[0-9]+\.[0-9]+\.[0-9]+$" false)
if [[ -n "$LAST_ALPHA" ]]; then
PREVIOUS_TAG="$LAST_ALPHA"
echo "📝 Rolling alpha release - comparing to last successful alpha: $PREVIOUS_TAG"
else
PREVIOUS_TAG="$LAST_STABLE"
echo "📝 First alpha for version - comparing to last successful stable: $PREVIOUS_TAG"
fi
;;
"beta")
# beta.X: Compare to last successful beta release or last stable release
LAST_BETA=$(find_last_successful_release "^v$CURRENT_VERSION-beta\." true)
LAST_STABLE=$(find_last_successful_release "^v[0-9]+\.[0-9]+\.[0-9]+$" false)
if [[ -n "$LAST_BETA" ]]; then
PREVIOUS_TAG="$LAST_BETA"
echo "📝 Rolling beta release - comparing to last successful beta: $PREVIOUS_TAG"
else
PREVIOUS_TAG="$LAST_STABLE"
echo "📝 First beta for version - comparing to last successful stable: $PREVIOUS_TAG"
fi
;;
"rc")
# rc.X: Compare to last successful RC release or last stable release
LAST_RC=$(find_last_successful_release "^v$CURRENT_VERSION-rc\." true)
LAST_STABLE=$(find_last_successful_release "^v[0-9]+\.[0-9]+\.[0-9]+$" false)
if [[ -n "$LAST_RC" ]]; then
PREVIOUS_TAG="$LAST_RC"
echo "📝 Rolling RC release - comparing to last successful RC: $PREVIOUS_TAG"
else
PREVIOUS_TAG="$LAST_STABLE"
echo "📝 First RC for version - comparing to last successful stable: $PREVIOUS_TAG"
fi
;;
esac
else
# Current is stable release - compare to last successful stable release
LAST_STABLE=$(find_last_successful_release "^v[0-9]+\.[0-9]+\.[0-9]+$" true)
if [[ -n "$LAST_STABLE" ]]; then
PREVIOUS_TAG="$LAST_STABLE"
echo "📝 Stable release - comparing to last successful stable: $PREVIOUS_TAG"
else
# Fallback to any previous tag
PREVIOUS_TAG=$(git describe --tags --abbrev=0 $TAG_NAME^ 2>/dev/null || echo "")
echo "📝 First stable release - comparing to: $PREVIOUS_TAG"
fi
fi
if [[ -n "$PREVIOUS_TAG" ]]; then
echo "✅ Generating release notes from $PREVIOUS_TAG to $TAG_NAME"
# Create comprehensive release notes
echo "## What's Changed" > release_notes.md
echo "" >> release_notes.md
# Create temporary files for grouping commits
temp_commits=$(mktemp)
temp_feat=$(mktemp)
temp_fix=$(mktemp)
temp_docs=$(mktemp)
temp_refactor=$(mktemp)
temp_test=$(mktemp)
temp_chore=$(mktemp)
temp_style=$(mktemp)
temp_other=$(mktemp)
git log $PREVIOUS_TAG..$TAG_NAME --pretty=format:"%H|%s|%an|%ae" --reverse > "$temp_commits"
# Process commits and group by type and scope
while IFS='|' read -r commit_hash commit_msg author author_email; do
# Skip version bump commits
if [[ $commit_msg =~ ^(Bump|Update\ Unity\ Package\ DLLs|release\ v) ]]; then
continue
fi
# Get short commit hash for display
SHORT_HASH="${commit_hash:0:7}"
# Parse different commit message formats
if [[ $commit_msg =~ ^Merge\ pull\ request\ #([0-9]+)\ from\ (.+)$ ]]; then
# PR merge: "Merge pull request #123 from branch"
PR_NUM="${BASH_REMATCH[1]}"
PR_TITLE=$(git log --format=%B -n 1 "$commit_hash" | sed -n '3p' | sed 's/^[[:space:]]*//')
if [[ -n "$PR_TITLE" && "$PR_TITLE" != "$commit_msg" ]]; then
echo "other|$PR_TITLE (#$PR_NUM) [$SHORT_HASH]|$author|$author_email" >> "$temp_other"
else
echo "other|Merged PR #$PR_NUM [$SHORT_HASH]|$author|$author_email" >> "$temp_other"
fi
elif [[ $commit_msg =~ ^(.+)\ \(#([0-9]+)\)$ ]]; then
# Squashed PR: "Feature title (#123)"
TITLE="${BASH_REMATCH[1]}"
PR_NUM="${BASH_REMATCH[2]}"
echo "other|$TITLE (#$PR_NUM) [$SHORT_HASH]|$author|$author_email" >> "$temp_other"
elif [[ $commit_msg =~ ^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?:(.+)$ ]]; then
# Conventional commit format
TYPE="${BASH_REMATCH[1]}"
SCOPE="${BASH_REMATCH[2]}"
DESC="${BASH_REMATCH[3]}"
# Group by type and scope for sorting
case "$TYPE" in
"feat")
echo "${SCOPE:-()}|${DESC} [$SHORT_HASH]|$author|$author_email" >> "$temp_feat"
;;
"fix")
echo "${SCOPE:-()}|${DESC} [$SHORT_HASH]|$author|$author_email" >> "$temp_fix"
;;
"docs")
echo "${SCOPE:-()}|${DESC} [$SHORT_HASH]|$author|$author_email" >> "$temp_docs"
;;
"refactor")
echo "${SCOPE:-()}|${DESC} [$SHORT_HASH]|$author|$author_email" >> "$temp_refactor"
;;
"test")
echo "${SCOPE:-()}|${DESC} [$SHORT_HASH]|$author|$author_email" >> "$temp_test"
;;
"chore")
echo "${SCOPE:-()}|${DESC} [$SHORT_HASH]|$author|$author_email" >> "$temp_chore"
;;
"style")
echo "${SCOPE:-()}|${DESC} [$SHORT_HASH]|$author|$author_email" >> "$temp_style"
;;
esac
else
# Direct commit
echo "other|$commit_msg [$SHORT_HASH]|$author|$author_email" >> "$temp_other"
fi
done < "$temp_commits"
# Function to add grouped section with scope sub-headers
add_section() {
local title="$1"
local file="$2"
local emoji="$3"
if [[ -s "$file" ]]; then
echo "" >> release_notes.md
echo "### $emoji $title" >> release_notes.md
echo "" >> release_notes.md
# Sort by scope, then group by scope with sub-headers
current_scope=""
sort -t'|' -k1,1 "$file" | while IFS='|' read -r scope_key entry author author_email; do
# Clean scope for display (remove parentheses)
display_scope=""
if [[ "$scope_key" != "()" && -n "$scope_key" ]]; then
display_scope=$(echo "$scope_key" | sed 's/[()]//g')
fi
# Add scope sub-header if scope changed
if [[ "$scope_key" != "$current_scope" ]]; then
if [[ -n "$display_scope" ]]; then
echo "" >> release_notes.md
echo "#### 📦 \`$display_scope\`" >> release_notes.md
echo "" >> release_notes.md
elif [[ "$scope_key" == "()" ]]; then
echo "" >> release_notes.md
echo "#### 🔧 General" >> release_notes.md
echo "" >> release_notes.md
fi
current_scope="$scope_key"
fi
# Create GitHub user link if not a bot
author_link=""
if [[ "$author" != "github-actions[bot]" && -n "$author_email" && "$author_email" != *"@users.noreply.github.com" ]]; then
# Try to extract GitHub username from email or use author name
if [[ "$author_email" =~ ^([^@]+)@users\.noreply\.github\.com$ ]]; then
github_username="${BASH_REMATCH[1]}"
author_link=" by [@$github_username](https://github.com/$github_username)"
else
# Use author name as fallback, trying to create a reasonable GitHub link
github_username=$(echo "$author" | tr '[:upper:]' '[:lower:]' | tr -d ' ')
author_link=" by [@$author](https://github.com/$github_username)"
fi
elif [[ "$author" != "github-actions[bot]" ]]; then
author_link=" by @$author"
fi
echo "- $entry$author_link" >> release_notes.md
done
fi
}
# Add sections in priority order
add_section "Features" "$temp_feat" "✨"
add_section "Bug Fixes" "$temp_fix" "🐛"
add_section "Improvements" "$temp_refactor" "🔧"
add_section "Documentation" "$temp_docs" "📚"
add_section "Testing" "$temp_test" "🧪"
add_section "Code Style" "$temp_style" "🎨"
add_section "Maintenance" "$temp_chore" "🏗️"
add_section "Other Changes" "$temp_other" "🔀"
# Cleanup temp files
rm -f "$temp_commits" "$temp_feat" "$temp_fix" "$temp_docs" "$temp_refactor" "$temp_test" "$temp_chore" "$temp_style" "$temp_other"
# Add contributors with GitHub profile links
echo "" >> release_notes.md
echo "### 👥 Contributors" >> release_notes.md
# Get unique contributors with their emails
git log $PREVIOUS_TAG..$TAG_NAME --pretty=format:"%an|%ae" | sort -u | while IFS='|' read -r author author_email; do
if [[ "$author" != "github-actions[bot]" ]]; then
# Try to create GitHub profile link
author_link=""
if [[ -n "$author_email" && "$author_email" =~ ^([^@]+)@users\.noreply\.github\.com$ ]]; then
# GitHub no-reply email format
github_username="${BASH_REMATCH[1]}"
# Handle numeric GitHub usernames (e.g., 12345+username@users.noreply.github.com)
if [[ "$github_username" =~ ^[0-9]+\+(.+)$ ]]; then
github_username="${BASH_REMATCH[1]}"
fi
author_link="[@$github_username](https://github.com/$github_username)"
elif [[ -n "$author_email" && "$author_email" != *"@users.noreply.github.com" ]]; then
# Try to guess GitHub username from author name
github_username=$(echo "$author" | tr '[:upper:]' '[:lower:]' | tr -d ' .' )
author_link="[@$author](https://github.com/$github_username)"
else
# Fallback to just author name
author_link="$author"
fi
echo "- $author_link" >> release_notes.md
fi
done
echo "" >> release_notes.md
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$PREVIOUS_TAG..$TAG_NAME" >> release_notes.md
# Add installation instructions
echo "" >> release_notes.md
if [[ "${{ needs.validate-tag.outputs.is_prerelease }}" == "true" ]]; then
echo "⚠️ **Pre-release** - Use with caution in production" >> release_notes.md
echo "" >> release_notes.md
echo "### Installation" >> release_notes.md
echo "**NuGet:**" >> release_notes.md
echo '```' >> release_notes.md
echo "dotnet add package Nino --version ${{ needs.validate-tag.outputs.full_version }}" >> release_notes.md
echo '```' >> release_notes.md
echo "**Unity:** Preview version available via UPM" >> release_notes.md
else
echo "### Installation" >> release_notes.md
echo "**NuGet:**" >> release_notes.md
echo '```' >> release_notes.md
echo "dotnet add package Nino" >> release_notes.md
echo '```' >> release_notes.md
echo "**Unity:** Stable version available via UPM" >> release_notes.md
fi
else
echo "No previous tag found, creating initial release notes"
echo "## Release $TAG_NAME" > release_notes.md
echo "" >> release_notes.md
echo "Initial release or no previous tags found." >> release_notes.md
fi
# Read release notes into output
{
echo 'RELEASE_NOTES<<EOF'
cat release_notes.md
echo 'EOF'
} >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body: ${{ steps.release_notes.outputs.RELEASE_NOTES }}
draft: false
prerelease: ${{ needs.validate-tag.outputs.is_prerelease }}
token: ${{ secrets.GITHUB_TOKEN }}
publish-nuget:
name: Publish to NuGet
runs-on: ubuntu-latest
needs: [validate-tag, create-release]
environment:
name: nuget-production
url: https://www.nuget.org/packages/Nino
steps:
- name: Download NuGet packages
uses: actions/download-artifact@v4
with:
name: nuget-packages
path: ./packages
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
${{ vars.DOTNET_VERSION || '8.0.x' }}
8.0.x
6.0.x
2.1.x
- name: Publish to NuGet
run: |
set -euo pipefail
echo "Publishing packages to NuGet..."
# Function to retry NuGet push with exponential backoff
retry_push() {
local package="$1"
local max_attempts=3
local attempt=1
local delay=5
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts: Publishing $package"
if dotnet nuget push "$package" \
--api-key ${{ secrets.MYTOKEN }} \
--source https://api.nuget.org/v3/index.json \
--skip-duplicate \
--no-symbols \
--timeout 300; then
echo "✅ Successfully published: $package"
return 0
fi
if [ $attempt -lt $max_attempts ]; then
echo "⚠️ Push failed, retrying in ${delay}s..."
sleep $delay
delay=$((delay * 2)) # Exponential backoff
fi
attempt=$((attempt + 1))
done
echo "❌ Failed to publish after $max_attempts attempts: $package"
return 1
}
# Publish each package with retry logic
failed_packages=()
for package in $(find ./packages -name "*.nupkg" | sort); do
if ! retry_push "$package"; then
failed_packages+=("$package")
fi
done
if [ ${#failed_packages[@]} -gt 0 ]; then
echo "❌ Failed to publish packages:"
printf '%s\n' "${failed_packages[@]}"
exit 1
fi
echo "✅ All packages published to NuGet successfully"
trigger-benchmark:
name: Trigger Benchmark
runs-on: ubuntu-latest
needs: [validate-tag, create-release]
if: success()
steps:
- name: Trigger benchmark workflow
uses: actions/github-script@v7
with:
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'report.yml',
ref: 'main',
inputs: {
tag_name: '${{ github.ref_name }}'
}
});
console.log('✅ Benchmark workflow triggered');
notify-completion:
name: Notify Release Completion
runs-on: ubuntu-latest
needs: [validate-tag, create-release, publish-nuget, trigger-benchmark, cleanup-release-branches]
if: always()
steps:
- name: Release Summary
run: |
echo "## 🚀 Release Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: ${{ needs.validate-tag.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Type**: ${{ needs.validate-tag.outputs.release_type }}" >> $GITHUB_STEP_SUMMARY
echo "- **Prerelease**: ${{ needs.validate-tag.outputs.is_prerelease }}" >> $GITHUB_STEP_SUMMARY
echo "- **Release URL**: ${{ needs.create-release.outputs.release_url }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.publish-nuget.result }}" == "success" ]]; then
echo "✅ **NuGet**: Published successfully" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **NuGet**: Publication failed" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.trigger-benchmark.result }}" == "success" ]]; then
echo "✅ **Benchmark**: Triggered successfully" >> $GITHUB_STEP_SUMMARY
else
echo "⚠️ **Benchmark**: Failed to trigger" >> $GITHUB_STEP_SUMMARY
fi
cleanup-release-branches:
name: Cleanup Old Release Branches
runs-on: ubuntu-latest
needs: [validate-tag, create-release]
if: success()
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Cleanup old pre-release branches
run: |
set -euo pipefail
CURRENT_VERSION="${{ needs.validate-tag.outputs.version }}"
CURRENT_IS_PRERELEASE="${{ needs.validate-tag.outputs.is_prerelease }}"
CURRENT_FULL_VERSION="${{ needs.validate-tag.outputs.full_version }}"
echo "🧹 Cleaning up old pre-release branches..."
echo "Current version: $CURRENT_VERSION"
echo "Current is prerelease: $CURRENT_IS_PRERELEASE"
# Get all remote branches matching release/* pattern
RELEASE_BRANCHES=$(git ls-remote --heads origin | grep -E "refs/heads/release/v?[0-9]+\.[0-9]+\.[0-9]+" | awk '{print $2}' | sed 's|refs/heads/||' || true)
if [[ -z "$RELEASE_BRANCHES" ]]; then
echo "ℹ️ No release branches found to clean up"
exit 0
fi
echo "Found release branches:"
echo "$RELEASE_BRANCHES"
echo ""
deleted_count=0
skipped_count=0
# Process each release branch
while IFS= read -r branch; do
if [[ -z "$branch" ]]; then
continue
fi
echo "Processing branch: $branch"
# Extract version from branch name (handle both release/v1.2.3-alpha.1 and release/1.2.3-alpha.1)
if [[ $branch =~ release/v?([0-9]+\.[0-9]+\.[0-9]+)(-([a-zA-Z]+)\.([0-9]+))?$ ]]; then
BRANCH_BASE_VERSION="${BASH_REMATCH[1]}"
BRANCH_PRERELEASE_LABEL="${BASH_REMATCH[3]}"
BRANCH_PRERELEASE_NUM="${BASH_REMATCH[4]}"
# Reconstruct full version for comparison
if [[ -n "$BRANCH_PRERELEASE_LABEL" ]]; then
BRANCH_FULL_VERSION="$BRANCH_BASE_VERSION-$BRANCH_PRERELEASE_LABEL.$BRANCH_PRERELEASE_NUM"
else
BRANCH_FULL_VERSION="$BRANCH_BASE_VERSION"
fi
echo " Branch version: $BRANCH_FULL_VERSION"
# Skip if this is the current release branch
if [[ "$BRANCH_FULL_VERSION" == "$CURRENT_FULL_VERSION" ]]; then
echo " ⏭️ Skipping current release branch"
skipped_count=$((skipped_count + 1))
continue
fi
# Delete old pre-release branches in these cases:
# 1. Any pre-release branch from previous versions
# 2. When releasing stable, delete all pre-release branches for this version
# 3. When releasing a different pre-release type (alpha->beta, beta->rc, etc.)
should_delete=false
delete_reason=""
if [[ -n "$BRANCH_PRERELEASE_LABEL" ]]; then
# This is a pre-release branch
if [[ "$BRANCH_BASE_VERSION" != "$CURRENT_VERSION" ]]; then
# Different version - always delete old pre-release branches
should_delete=true
delete_reason="old version ($BRANCH_BASE_VERSION != $CURRENT_VERSION)"
elif [[ "$CURRENT_IS_PRERELEASE" == "false" ]]; then
# Current is stable, delete all pre-release branches for this version
should_delete=true
delete_reason="stable release, cleaning up pre-release branches"
elif [[ "$CURRENT_IS_PRERELEASE" == "true" ]]; then
# Both are pre-releases of same version - check if different type or older
CURRENT_TYPE="${{ needs.validate-tag.outputs.release_type }}"
if [[ "$BRANCH_PRERELEASE_LABEL" != "$CURRENT_TYPE" ]]; then
# Different pre-release type (e.g., alpha vs beta)
should_delete=true
delete_reason="different pre-release type ($BRANCH_PRERELEASE_LABEL != $CURRENT_TYPE)"
else
# Same type, check if older
CURRENT_PRERELEASE_NUM=$(echo "$CURRENT_FULL_VERSION" | grep -o '\.[0-9]*$' | sed 's/\.//')
if [[ "$BRANCH_PRERELEASE_NUM" < "$CURRENT_PRERELEASE_NUM" ]]; then
should_delete=true
delete_reason="older pre-release number ($BRANCH_PRERELEASE_NUM < $CURRENT_PRERELEASE_NUM)"
fi
fi
fi
else
# This is a stable release branch (shouldn't normally exist, but handle it)
echo " ℹ️ Found stable release branch (unusual): $branch"
skipped_count=$((skipped_count + 1))
continue
fi
if [[ "$should_delete" == "true" ]]; then
echo " 🗑️ Deleting branch: $delete_reason"
if git push origin --delete "$branch" 2>/dev/null; then
echo " ✅ Successfully deleted branch: $branch"
deleted_count=$((deleted_count + 1))
else
echo " ⚠️ Failed to delete branch: $branch (may have been already deleted)"
fi
else
echo " ⏭️ Keeping branch: $branch"
skipped_count=$((skipped_count + 1))
fi
else
echo " ⚠️ Could not parse version from branch name: $branch"
skipped_count=$((skipped_count + 1))
fi
echo ""
done <<< "$RELEASE_BRANCHES"
echo "🧹 Cleanup summary:"
echo " - Deleted: $deleted_count branches"
echo " - Skipped: $skipped_count branches"
if [[ $deleted_count -gt 0 ]]; then
echo "✅ Successfully cleaned up $deleted_count old release branches"
else
echo "ℹ️ No branches needed cleanup"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}