99 branches :
1010 - main
1111 paths :
12- - .github/workflows/cd.yml
13- - Justfile
14- - global.json
1512 - package.json
16- - benchmarks/**
17- - samples/**
18- - src/**
1913
2014jobs :
2115 release :
3529 with :
3630 fetch-depth : 0
3731
38- - id : release-trigger
39- name : Determine whether to publish a release
40- env :
41- EVENT_NAME : ${{ github.event_name }}
42- BEFORE_SHA : ${{ github.event.before }}
43- CURRENT_SHA : ${{ github.sha }}
44- shell : bash
45- run : |
46- set -euo pipefail
47-
48- if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
49- echo "should_release=true" >> "${GITHUB_OUTPUT}"
50- exit 0
51- fi
52-
53- if [[ "${BEFORE_SHA}" == "0000000000000000000000000000000000000000" ]]; then
54- changed_files="$(git show --name-only --pretty='' "${CURRENT_SHA}")"
55- else
56- changed_files="$(git diff --name-only "${BEFORE_SHA}" "${CURRENT_SHA}")"
57- fi
58-
59- if grep -Fxq "package.json" <<< "${changed_files}"; then
60- echo "should_release=true" >> "${GITHUB_OUTPUT}"
61- else
62- echo "should_release=false" >> "${GITHUB_OUTPUT}"
63- fi
64-
6532 - id : version
6633 name : Read release version
67- if : ${{ steps.release-trigger.outputs.should_release == 'true' }}
6834 shell : bash
6935 run : |
7036 set -euo pipefail
@@ -79,74 +45,62 @@ jobs:
7945 echo "prerelease=false" >> "${GITHUB_OUTPUT}"
8046 fi
8147
82- - name : Block duplicate version
83- if : ${{ steps. release-trigger.outputs.should_release == 'true' }}
48+ - id : release-check
49+ name : Check if release already exists
8450 env :
85- GITHUB_TOKEN : ${{ github.token }}
51+ GH_TOKEN : ${{ github.token }}
8652 RELEASE_TAG : ${{ steps.version.outputs.tag }}
8753 shell : bash
8854 run : |
8955 set -euo pipefail
9056
91- if git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" > /dev/null 2>&1; then
92- echo "::error::Tag ${RELEASE_TAG} already exists on origin. Refusing to replace an existing release version."
93- exit 1
94- fi
95-
96- release_status="$(
97- curl -sS \
98- -o release.json \
99- -w "%{http_code}" \
100- -H "Authorization: Bearer ${GITHUB_TOKEN}" \
101- -H "Accept: application/vnd.github+json" \
102- "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}"
103- )"
104-
105- if [[ "${release_status}" == "200" ]]; then
106- echo "::error::Release ${RELEASE_TAG} already exists. Refusing to replace an existing release version."
107- cat release.json
108- exit 1
109- fi
110-
111- if [[ "${release_status}" != "404" ]]; then
112- echo "::error::Unexpected GitHub API response while checking ${RELEASE_TAG} (HTTP ${release_status})."
113- cat release.json
114- exit 1
57+ if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" &>/dev/null; then
58+ echo "::notice::Release ${RELEASE_TAG} already exists — skipping."
59+ echo "should_release=false" >> "${GITHUB_OUTPUT}"
60+ else
61+ echo "should_release=true" >> "${GITHUB_OUTPUT}"
11562 fi
11663
117- rm -f release.json
118-
11964 - name : Set up .NET
65+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
12066 uses : actions/setup-dotnet@v5
12167 with :
12268 global-json-file : ./global.json
12369
12470 - name : Restore dotnet tools
71+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
12572 run : dotnet tool restore
12673
12774 - name : CSharpier format check
75+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
12876 run : dotnet csharpier check .
12977
13078 - name : Restore main solution
79+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
13180 run : dotnet restore ${{ env.MAIN_SOLUTION }}
13281
13382 - name : Build main solution
83+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
13484 run : dotnet build ${{ env.MAIN_SOLUTION }} --no-restore --configuration ${{ env.CONFIGURATION }}
13585
13686 - name : Test main solution
137- run : dotnet test --solution ${{ env.MAIN_SOLUTION }} --no-build --configuration ${{ env.CONFIGURATION }} --verbosity normal
87+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
88+ run : dotnet test ${{ env.MAIN_SOLUTION }} --no-build --configuration ${{ env.CONFIGURATION }} --verbosity normal
13889
13990 - name : Restore sample solution
91+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
14092 run : dotnet restore ${{ env.SAMPLE_SOLUTION }}
14193
14294 - name : Build sample solution
95+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
14396 run : dotnet build ${{ env.SAMPLE_SOLUTION }} --no-restore --configuration ${{ env.CONFIGURATION }}
14497
14598 - name : Test sample solution
146- run : dotnet test --solution ${{ env.SAMPLE_SOLUTION }} --no-build --configuration ${{ env.CONFIGURATION }} --verbosity normal
99+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
100+ run : dotnet test ${{ env.SAMPLE_SOLUTION }} --no-build --configuration ${{ env.CONFIGURATION }} --verbosity normal
147101
148102 - name : Pack NuGet artifacts
149- if : ${{ steps.release-trigger .outputs.should_release == 'true' }}
103+ if : ${{ steps.release-check .outputs.should_release == 'true' }}
150104 env :
151105 PACKAGE_VERSION : ${{ steps.version.outputs.value }}
152106 shell : bash
@@ -165,20 +119,47 @@ jobs:
165119 "/p:RepositoryCommit=${GITHUB_SHA}" \
166120 "/p:COPYRIGHT_YEAR=$(date -u +%Y)"
167121
168- - name : Create GitHub release
169- if : ${{ steps.release-trigger.outputs.should_release == 'true' }}
170- uses : softprops/action-gh-release@v3
171- with :
172- tag_name : ${{ steps.version.outputs.tag }}
173- name : ${{ steps.version.outputs.tag }}
174- target_commitish : ${{ github.sha }}
175- generate_release_notes : true
176- prerelease : ${{ steps.version.outputs.prerelease }}
177- fail_on_unmatched_files : true
178- files : |
179- ./artifacts/nuget/*.nupkg
122+ - name : Publish GitHub Release
123+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
124+ env :
125+ GH_TOKEN : ${{ github.token }}
126+ RELEASE_TAG : ${{ steps.version.outputs.tag }}
127+ PRERELEASE : ${{ steps.version.outputs.prerelease }}
128+ shell : bash
129+ run : |
130+ set -euo pipefail
131+
132+ # On any error: delete the draft release and its tag so the next run starts clean.
133+ # Draft releases are never immutable, so this always succeeds.
134+ cleanup() {
135+ echo "::warning::Release failed — cleaning up draft and tag for ${RELEASE_TAG}..."
136+ gh release delete "${RELEASE_TAG}" --yes --cleanup-tag 2>/dev/null || true
137+ }
138+ trap cleanup ERR
139+
140+ PRERELEASE_FLAG=""
141+ if [[ "${PRERELEASE}" == "true" ]]; then
142+ PRERELEASE_FLAG="--prerelease"
143+ fi
144+
145+ # Create as draft first — draft releases are never immutable, so assets
146+ # can be freely attached. The release becomes immutable only after --draft=false.
147+ gh release create "${RELEASE_TAG}" \
148+ --draft \
149+ --title "${RELEASE_TAG}" \
150+ --generate-notes \
151+ ${PRERELEASE_FLAG} \
152+ ./artifacts/nuget/*.nupkg \
180153 ./artifacts/nuget/*.snupkg
181154
155+ # Publish: marks as latest and becomes immutable. Any retry will clean up above.
156+ gh release edit "${RELEASE_TAG}" \
157+ --draft=false \
158+ --latest
159+
160+ echo "✅ Release ${RELEASE_TAG} published successfully."
161+
182162 - name : Skip release publish
183- if : ${{ steps.release-trigger.outputs.should_release != 'true' }}
184- run : echo "package.json did not change on this push, so release publishing was skipped."
163+ if : ${{ steps.release-check.outputs.should_release != 'true' }}
164+ run : echo "Release ${{ steps.version.outputs.tag }} already exists — skipping."
165+
0 commit comments