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,72 +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- # A pre-existing tag is expected: commit-and-tag-version creates the tag
92- # locally and it may be pushed before this workflow runs. Only an existing
93- # GitHub Release is a hard error (it means the release was already published).
94- release_status="$(
95- curl -sS \
96- -o release.json \
97- -w "%{http_code}" \
98- -H "Authorization: Bearer ${GITHUB_TOKEN}" \
99- -H "Accept: application/vnd.github+json" \
100- "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}"
101- )"
102-
103- if [[ "${release_status}" == "200" ]]; then
104- echo "::error::Release ${RELEASE_TAG} already exists. Refusing to replace an existing release version."
105- cat release.json
106- exit 1
107- fi
108-
109- if [[ "${release_status}" != "404" ]]; then
110- echo "::error::Unexpected GitHub API response while checking ${RELEASE_TAG} (HTTP ${release_status})."
111- cat release.json
112- 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}"
11362 fi
11463
115- rm -f release.json
116-
11764 - name : Set up .NET
65+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
11866 uses : actions/setup-dotnet@v5
11967 with :
12068 global-json-file : ./global.json
12169
12270 - name : Restore dotnet tools
71+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
12372 run : dotnet tool restore
12473
12574 - name : CSharpier format check
75+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
12676 run : dotnet csharpier check .
12777
12878 - name : Restore main solution
79+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
12980 run : dotnet restore ${{ env.MAIN_SOLUTION }}
13081
13182 - name : Build main solution
83+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
13284 run : dotnet build ${{ env.MAIN_SOLUTION }} --no-restore --configuration ${{ env.CONFIGURATION }}
13385
13486 - name : Test main solution
135- 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
13689
13790 - name : Restore sample solution
91+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
13892 run : dotnet restore ${{ env.SAMPLE_SOLUTION }}
13993
14094 - name : Build sample solution
95+ if : ${{ steps.release-check.outputs.should_release == 'true' }}
14196 run : dotnet build ${{ env.SAMPLE_SOLUTION }} --no-restore --configuration ${{ env.CONFIGURATION }}
14297
14398 - name : Test sample solution
144- 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
145101
146102 - name : Pack NuGet artifacts
147- if : ${{ steps.release-trigger .outputs.should_release == 'true' }}
103+ if : ${{ steps.release-check .outputs.should_release == 'true' }}
148104 env :
149105 PACKAGE_VERSION : ${{ steps.version.outputs.value }}
150106 shell : bash
@@ -163,20 +119,47 @@ jobs:
163119 "/p:RepositoryCommit=${GITHUB_SHA}" \
164120 "/p:COPYRIGHT_YEAR=$(date -u +%Y)"
165121
166- - name : Create GitHub release
167- if : ${{ steps.release-trigger.outputs.should_release == 'true' }}
168- uses : softprops/action-gh-release@v3
169- with :
170- tag_name : ${{ steps.version.outputs.tag }}
171- name : ${{ steps.version.outputs.tag }}
172- target_commitish : ${{ github.sha }}
173- generate_release_notes : true
174- prerelease : ${{ steps.version.outputs.prerelease }}
175- fail_on_unmatched_files : true
176- files : |
177- ./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 \
178153 ./artifacts/nuget/*.snupkg
179154
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+
180162 - name : Skip release publish
181- if : ${{ steps.release-trigger.outputs.should_release != 'true' }}
182- 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