-
Notifications
You must be signed in to change notification settings - Fork 57
1259 lines (1086 loc) · 53 KB
/
release.yml
File metadata and controls
1259 lines (1086 loc) · 53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
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 }}
unity_version: ${{ steps.parse.outputs.unity_version }}
should_skip: ${{ steps.parse.outputs.should_skip }}
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"
# Skip OpenUPM-only preview/pre/exp tags to prevent re-trigger loops
if [[ $TAG_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+-(preview|pre|exp)\.[0-9]+$ ]]; then
echo "Skipping OpenUPM-only tag: $TAG_NAME"
echo "should_skip=true" >> $GITHUB_OUTPUT
exit 0
fi
echo "should_skip=false" >> $GITHUB_OUTPUT
# 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
# Calculate Unity/OpenUPM-compatible version using offset system
# alpha: 0-99, beta: 100-199, rc: 200-299
if [[ "$IS_PRERELEASE" == "true" ]]; then
case "$RELEASE_TYPE" in
"alpha") PREVIEW_NUM=$((PRERELEASE_NUM)) ;;
"beta") PREVIEW_NUM=$((100 + PRERELEASE_NUM)) ;;
"rc") PREVIEW_NUM=$((200 + PRERELEASE_NUM)) ;;
esac
UNITY_VERSION="$VERSION-preview.$PREVIEW_NUM"
else
UNITY_VERSION="$VERSION"
fi
echo "unity_version=$UNITY_VERSION" >> $GITHUB_OUTPUT
echo "✅ Valid version tag: $FULL_VERSION (prerelease: $IS_PRERELEASE, unity: $UNITY_VERSION)"
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]
if: needs.validate-tag.outputs.should_skip != 'true'
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]
if: needs.validate-tag.outputs.should_skip != 'true'
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]
if: needs.validate-tag.outputs.should_skip != 'true'
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
# Create OpenUPM-compatible preview tag for pre-releases
if [[ "$IS_PRERELEASE" == "true" ]]; then
UNITY_VERSION="${{ needs.validate-tag.outputs.unity_version }}"
UNITY_TAG="v$UNITY_VERSION"
# Remove existing preview tag if present (from a previous failed run)
git push origin --delete "$UNITY_TAG" 2>/dev/null || true
git tag -d "$UNITY_TAG" 2>/dev/null || true
git tag -a "$UNITY_TAG" -m "Release $UNITY_TAG (Unity/OpenUPM)" "$UNITY_DLL_COMMIT"
# Push with retry (non-blocking: failure does NOT fail the release)
preview_attempts=3; preview_attempt=1; preview_delay=5
while [ $preview_attempt -le $preview_attempts ]; do
if git push origin "$UNITY_TAG"; then
echo "✅ Created OpenUPM tag $UNITY_TAG pointing to $UNITY_DLL_COMMIT"
break
fi
if [ $preview_attempt -lt $preview_attempts ]; then
sleep $preview_delay; preview_delay=$((preview_delay * 2))
else
echo "⚠️ Failed to push OpenUPM tag. Manual fix:"
echo " git tag -a $UNITY_TAG -m 'Release $UNITY_TAG' $UNITY_DLL_COMMIT && git push origin $UNITY_TAG"
fi
preview_attempt=$((preview_attempt + 1))
done
fi
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]
if: needs.validate-tag.outputs.should_skip != 'true'
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_perf=$(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"
# Build a cache of author→GitHub username mappings using the commit API
declare -A USERNAME_CACHE
while IFS='|' read -r commit_hash _msg cache_author cache_email; do
cache_key="${cache_author}|${cache_email}"
# Skip if already cached
if [[ -n "${USERNAME_CACHE[$cache_key]+x}" ]]; then
continue
fi
# Skip bots
if [[ "$cache_author" == "github-actions[bot]" ]]; then
USERNAME_CACHE["$cache_key"]="__bot__"
continue
fi
# Claude AI (Anthropic) → claude[bot], not the unrelated human user
if [[ "$cache_email" == "noreply@anthropic.com" ]]; then
USERNAME_CACHE["$cache_key"]="claude[bot]"
continue
fi
# Noreply emails: extract username directly (no API call needed)
if [[ "$cache_email" =~ ^([0-9]+\+)?([^@]+)@users\.noreply\.github\.com$ ]]; then
USERNAME_CACHE["$cache_key"]="${BASH_REMATCH[2]}"
continue
fi
# All other emails: resolve via commit API
resolved=$(gh api "repos/${{ github.repository }}/commits/${commit_hash}" --jq '.author.login // empty' 2>/dev/null || echo "")
if [[ -n "$resolved" ]]; then
USERNAME_CACHE["$cache_key"]="$resolved"
else
USERNAME_CACHE["$cache_key"]="__unknown__"
fi
done < "$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|perf|docs|style|refactor|test|chore)(\(.+\))?:(.+)$ ]]; then
# Conventional commit format
TYPE="${BASH_REMATCH[1]}"
SCOPE="${BASH_REMATCH[2]}"
DESC="${BASH_REMATCH[3]}"
# Helper function to add entry to appropriate temp file
add_to_temp_file() {
local scope_val="$1"
local type_val="$2"
local desc_val="$3"
local hash_val="$4"
local author_val="$5"
local email_val="$6"
local target_file=""
case "$type_val" in
"feat") target_file="$temp_feat" ;;
"fix") target_file="$temp_fix" ;;
"perf") target_file="$temp_perf" ;;
"docs") target_file="$temp_docs" ;;
"refactor") target_file="$temp_refactor" ;;
"test") target_file="$temp_test" ;;
"chore") target_file="$temp_chore" ;;
"style") target_file="$temp_style" ;;
esac
if [[ -n "$target_file" ]]; then
echo "${scope_val}|${desc_val} [$hash_val]|$author_val|$email_val" >> "$target_file"
fi
}
# Split scopes by comma and add entry for each
if [[ -n "$SCOPE" ]]; then
# Remove parentheses and split by comma
SCOPE_CLEAN=$(echo "$SCOPE" | sed 's/[()]//g')
# Check if scope contains comma
if [[ "$SCOPE_CLEAN" == *","* ]]; then
# Multiple scopes - add entry for each
IFS=',' read -ra SCOPE_ARRAY <<< "$SCOPE_CLEAN"
for single_scope in "${SCOPE_ARRAY[@]}"; do
# Trim whitespace
single_scope=$(echo "$single_scope" | xargs)
add_to_temp_file "($single_scope)" "$TYPE" "$DESC" "$SHORT_HASH" "$author" "$author_email"
done
else
# Single scope
add_to_temp_file "($SCOPE_CLEAN)" "$TYPE" "$DESC" "$SHORT_HASH" "$author" "$author_email"
fi
else
# No scope
add_to_temp_file "()" "$TYPE" "$DESC" "$SHORT_HASH" "$author" "$author_email"
fi
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=""
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 from cache
author_link=""
cache_key="${author}|${author_email}"
cached_username="${USERNAME_CACHE[$cache_key]:-}"
if [[ "$cached_username" == "__bot__" || -z "$cached_username" ]]; then
author_link=""
elif [[ "$cached_username" == "__unknown__" ]]; then
author_link=" by $author"
else
author_link=" by @$cached_username"
fi
echo "- $entry$author_link" >> release_notes.md
done < <(sort -t'|' -k1,1 "$file")
fi
}
# Add sections in priority order
add_section "Features" "$temp_feat" "✨"
add_section "Bug Fixes" "$temp_fix" "🐛"
add_section "Performance" "$temp_perf" "⚡"
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" "🔀"
# Count contributions per resolved username (before temp file cleanup)
declare -A CONTRIBUTION_COUNT
declare -A UNKNOWN_DISPLAY # maps "__unknown__:displayname" -> displayname
while IFS='|' read -r _hash _msg count_author count_email; do
cache_key="${count_author}|${count_email}"
resolved="${USERNAME_CACHE[$cache_key]:-}"
if [[ -z "$resolved" || "$resolved" == "__bot__" ]]; then
continue
fi
if [[ "$resolved" == "__unknown__" ]]; then
contrib_key="__unknown__:${count_author}"
UNKNOWN_DISPLAY["$contrib_key"]="$count_author"
else
contrib_key="$resolved"
fi
CONTRIBUTION_COUNT["$contrib_key"]=$(( ${CONTRIBUTION_COUNT["$contrib_key"]:-0} + 1 ))
done < "$temp_commits"
# Cleanup temp files
rm -f "$temp_commits" "$temp_feat" "$temp_fix" "$temp_perf" "$temp_docs" "$temp_refactor" "$temp_test" "$temp_chore" "$temp_style" "$temp_other"
# Add contributors sorted by contribution count (descending)
echo "" >> release_notes.md
echo "### 👥 Contributors" >> release_notes.md
for contrib_key in "${!CONTRIBUTION_COUNT[@]}"; do
echo "${CONTRIBUTION_COUNT[$contrib_key]}|${contrib_key}"
done | sort -t'|' -k1,1 -rn | while IFS='|' read -r _count contrib_key; do
if [[ "$contrib_key" == __unknown__:* ]]; then
echo "- ${UNKNOWN_DISPLAY[$contrib_key]}" >> release_notes.md
else
echo "- @$contrib_key" >> 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]
if: needs.validate-tag.outputs.should_skip != 'true'
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