3939 with :
4040 tag : ${{ steps.get_version.outputs.current-version }}
4141
42+ # Generates release notes (release.md) in parallel with tests/builds.
43+ # The artifact is consumed by release-unity-plugin's atomic publish step.
44+ prepare-release-notes :
45+ runs-on : ubuntu-latest
46+ needs : [check-version-tag]
47+ if : needs.check-version-tag.outputs.tag_exists == 'false'
48+ steps :
49+ - name : Checkout repository
50+ uses : actions/checkout@v6
51+ with :
52+ fetch-depth : 0
53+ fetch-tags : true
54+
55+ - name : Generate release description
56+ env :
57+ GH_TOKEN : ${{ github.token }}
58+ run : |
59+ set -e
60+ version=${{ needs.check-version-tag.outputs.version }}
61+ prev_tag=${{ needs.check-version-tag.outputs.prev_tag }}
62+ repo_url="https://github.com/${GITHUB_REPOSITORY}"
63+ today=$(date +'%B %e, %Y')
64+
65+ echo "repo_url: $repo_url"
66+ echo "today: $today"
67+
68+ echo "# Package $version" > release.md
69+ echo "**Released:** *$today*" >> release.md
70+
71+ echo "" >> release.md
72+ echo "---" >> release.md
73+ echo "" >> release.md
74+
75+ if [ -n "$prev_tag" ]; then
76+ echo "## Comparison" >> release.md
77+ echo "See every change: [Compare $prev_tag...$version]($repo_url/compare/$prev_tag...$version)" >> release.md
78+
79+ echo "" >> release.md
80+ echo "---" >> release.md
81+ echo "" >> release.md
82+
83+ echo "## Commit Summary (Newest → Oldest)" >> release.md
84+ for sha in $(git log --pretty=format:'%H' $prev_tag..HEAD); do
85+ username=$(gh api repos/${GITHUB_REPOSITORY}/commits/$sha --jq '.author.login // .commit.author.name' 2>/dev/null || true)
86+ if [ -z "$username" ]; then
87+ username=$(git log -1 --pretty=format:'%an' $sha)
88+ fi
89+ message=$(git log -1 --pretty=format:'%s' $sha)
90+ short_sha=$(git log -1 --pretty=format:'%h' $sha)
91+ echo "- [\`$short_sha\`]($repo_url/commit/$sha) — $message by @$username" >> release.md
92+ done
93+ fi
94+
95+ - name : Upload release notes as artifact
96+ uses : actions/upload-artifact@v6
97+ with :
98+ name : release-notes
99+ path : ./release.md
100+
42101 build-unity-installer :
43102 runs-on : ubuntu-latest
44103 needs : [check-version-tag]
@@ -112,6 +171,99 @@ jobs:
112171 name : unity-installer-package
113172 path : ./Installer/build/AI-ParticleSystem-Installer.unitypackage
114173
174+ # Builds the signed UPM package (.tgz with package/.attestation.p7m) in parallel
175+ # with tests/builds and uploads it as a `signed-upm-package` artifact for the
176+ # atomic release publish in release-unity-plugin.
177+ #
178+ # HARD-GATE: this job is NOT continue-on-error. Missing UPM signing secrets fail
179+ # fast and the release pipeline halts — no GitHub Release is created without a
180+ # signed UPM tarball. See docs/openupm-signing.md for the blocking-semantics
181+ # rationale.
182+ #
183+ # Required repo secrets (configure via `gh secret set --repo IvanMurzak/Unity-AI-ParticleSystem <NAME>`):
184+ # - UPM_SERVICE_ACCOUNT_KEY_ID
185+ # - UPM_SERVICE_ACCOUNT_KEY_SECRET
186+ # - UPM_ORG_ID
187+ # See https://openupm.com/docs/signing-upm-packages.html for the procedure.
188+ build-signed-upm-package :
189+ runs-on : ubuntu-latest
190+ needs : [check-version-tag]
191+ if : needs.check-version-tag.outputs.tag_exists == 'false'
192+ env :
193+ UPM_SERVICE_ACCOUNT_KEY_ID : ${{ secrets.UPM_SERVICE_ACCOUNT_KEY_ID }}
194+ UPM_SERVICE_ACCOUNT_KEY_SECRET : ${{ secrets.UPM_SERVICE_ACCOUNT_KEY_SECRET }}
195+ UPM_ORG_ID : ${{ secrets.UPM_ORG_ID }}
196+ PACKAGE_DIR : Unity-Package/Assets/root
197+ DIST_DIR : /tmp/signed-upm-dist
198+ steps :
199+ - name : Verify signing secrets are configured
200+ run : |
201+ missing=()
202+ [ -z "$UPM_SERVICE_ACCOUNT_KEY_ID" ] && missing+=("UPM_SERVICE_ACCOUNT_KEY_ID")
203+ [ -z "$UPM_SERVICE_ACCOUNT_KEY_SECRET" ] && missing+=("UPM_SERVICE_ACCOUNT_KEY_SECRET")
204+ [ -z "$UPM_ORG_ID" ] && missing+=("UPM_ORG_ID")
205+ if [ "${#missing[@]}" -ne 0 ]; then
206+ printf '::error::UPM signing secrets are not configured (%s). The release pipeline is hard-gated on signing — see docs/openupm-signing.md for setup.\n' "${missing[*]}"
207+ exit 1
208+ fi
209+
210+ - name : Checkout repository
211+ uses : actions/checkout@v6
212+
213+ - name : Log package metadata
214+ run : |
215+ package_name="$(jq -r '.name' "$PACKAGE_DIR/package.json")"
216+ package_version="$(jq -r '.version' "$PACKAGE_DIR/package.json")"
217+
218+ printf 'Package name: %s\n' "$package_name"
219+ printf 'Package version: %s\n' "$package_version"
220+
221+ - name : Install Unity UPM CLI
222+ run : |
223+ curl -fsSL https://cdn.packages.unity.com/upm-cli/install.sh -o install.sh
224+ bash install.sh
225+ echo "$HOME/.upm/bin" >> "$GITHUB_PATH"
226+
227+ - name : Verify Unity UPM CLI
228+ run : upm --help
229+
230+ - name : Sign package
231+ run : |
232+ mkdir -p "$DIST_DIR"
233+ upm pack "./$PACKAGE_DIR" --organization-id "$UPM_ORG_ID" --destination "$DIST_DIR"
234+
235+ - name : Verify signed package contains attestation
236+ run : |
237+ shopt -s nullglob
238+ archives=("$DIST_DIR"/*.tgz "$DIST_DIR"/*.tar.gz)
239+ if [ "${#archives[@]}" -ne 1 ]; then
240+ printf 'Expected exactly one signed package archive, found %s: %s\n' "${#archives[@]}" "${archives[*]:-<none>}" >&2
241+ exit 1
242+ fi
243+
244+ archive="${archives[0]}"
245+ archive_basename="$(basename "$archive")"
246+ # OpenUPM consumes the asset via the `githubReleaseAssetName: 'com.ivanmurzak.unity.mcp.particlesystem-'`
247+ # prefix documented in docs/openupm-signing.md. Enforce the contract here so a future
248+ # `upm pack` naming change fails CI loudly instead of silently breaking OpenUPM pickup.
249+ if [[ "$archive_basename" != com.ivanmurzak.unity.mcp.particlesystem-* ]]; then
250+ printf 'Signed archive basename %q does not begin with the OpenUPM-expected prefix com.ivanmurzak.unity.mcp.particlesystem- (see docs/openupm-signing.md)\n' "$archive_basename" >&2
251+ exit 1
252+ fi
253+
254+ archive_entries="$(tar -tzf "$archive")"
255+ grep -qx 'package/package.json' <<<"$archive_entries"
256+ grep -qx 'package/.attestation.p7m' <<<"$archive_entries"
257+
258+ printf 'Signed archive: %s\n' "$archive_basename"
259+ tar -xOzf "$archive" package/package.json | jq '{name, version}'
260+
261+ - name : Upload signed UPM package as artifact
262+ uses : actions/upload-artifact@v6
263+ with :
264+ name : signed-upm-package
265+ path : /tmp/signed-upm-dist/*.tgz
266+
115267 # --- UNITY TESTS ---
116268 # -------------------
117269
@@ -204,12 +356,19 @@ jobs:
204356
205357 # -------------------
206358
359+ # Atomic publish point — gated on EVERY prerequisite (tests, installer build,
360+ # signed UPM package, release notes). Downloads all asset artifacts and creates
361+ # the GitHub Release + tag with the full asset set in a SINGLE
362+ # softprops/action-gh-release@v2 call so a failed upload cannot strand the
363+ # release with incomplete assets. Signing failure → no release (hard gate).
207364 release-unity-plugin :
208365 runs-on : ubuntu-latest
209366 needs :
210367 [
211368 check-version-tag,
369+ prepare-release-notes,
212370 build-unity-installer,
371+ build-signed-upm-package,
213372 test-unity-2022-3-62f3-editmode,
214373 test-unity-2022-3-62f3-playmode,
215374 test-unity-2022-3-62f3-standalone,
@@ -223,98 +382,72 @@ jobs:
223382 if : needs.check-version-tag.outputs.tag_exists == 'false'
224383 outputs :
225384 version : ${{ needs.check-version-tag.outputs.version }}
226- success : ${{ steps.rel_desc.outputs.success }}
227- release_notes : ${{ steps.rel_desc.outputs.release_body }}
228385 steps :
229- - name : Checkout repository
230- uses : actions/checkout @v6
386+ - name : Download release notes artifact
387+ uses : actions/download-artifact @v6
231388 with :
232- fetch-depth : 0
233- fetch-tags : true
234-
235- - name : Generate release description
236- id : rel_desc
237- env :
238- GH_TOKEN : ${{ github.token }}
239- run : |
240- set -e
241- version=${{ needs.check-version-tag.outputs.version }}
242- prev_tag=${{ needs.check-version-tag.outputs.prev_tag }}
243- repo_url="https://github.com/${GITHUB_REPOSITORY}"
244- today=$(date +'%B %e, %Y')
245-
246- echo "repo_url: $repo_url"
247- echo "today: $today"
389+ name : release-notes
390+ path : ./release-notes
248391
249- echo "# Package $version" > release.md
250- echo "**Released:** *$today*" >> release.md
251-
252- echo "" >> release.md
253- echo "---" >> release.md
254- echo "" >> release.md
255-
256- if [ -n "$prev_tag" ]; then
257- echo "## Comparison" >> release.md
258- echo "See every change: [Compare $prev_tag...$version]($repo_url/compare/$prev_tag...$version)" >> release.md
259-
260- echo "" >> release.md
261- echo "---" >> release.md
262- echo "" >> release.md
392+ - name : Download Unity installer artifact
393+ uses : actions/download-artifact@v6
394+ with :
395+ name : unity-installer-package
396+ path : ./assets
263397
264- echo "## Commit Summary (Newest → Oldest)" >> release.md
265- for sha in $(git log --pretty=format:'%H' $prev_tag..HEAD); do
266- username=$(gh api repos/${GITHUB_REPOSITORY}/commits/$sha --jq '.author.login // .commit.author.name' 2>/dev/null || true)
267- if [ -z "$username" ]; then
268- username=$(git log -1 --pretty=format:'%an' $sha)
269- fi
270- message=$(git log -1 --pretty=format:'%s' $sha)
271- short_sha=$(git log -1 --pretty=format:'%h' $sha)
272- echo "- [\`$short_sha\`]($repo_url/commit/$sha) — $message by @$username" >> release.md
273- done
274- fi
398+ - name : Download signed UPM package artifact
399+ uses : actions/download-artifact@v6
400+ with :
401+ name : signed-upm-package
402+ path : ./assets
275403
276- printf "release_body<<ENDOFRELEASEBODY\n%s\nENDOFRELEASEBODY\n" "$(cat release.md)" >> $GITHUB_OUTPUT
277- echo "success=true" >> $GITHUB_OUTPUT
404+ - name : List assembled release assets
405+ run : |
406+ set -e
407+ echo "Release notes:"
408+ ls -la ./release-notes
409+ echo ""
410+ echo "Release assets:"
411+ ls -la ./assets
278412
279- - name : Create Tag and Release
413+ - name : Create Tag and Release with all assets
280414 uses : softprops/action-gh-release@v2
281415 with :
282416 tag_name : ${{ needs.check-version-tag.outputs.version }}
283417 name : ${{ needs.check-version-tag.outputs.version }}
284- body : ${{ steps.rel_desc.outputs.release_body }}
418+ body_path : ./release-notes/release.md
285419 draft : false
286420 prerelease : false
421+ fail_on_unmatched_files : true
422+ files : |
423+ ./assets/AI-ParticleSystem-Installer.unitypackage
424+ ./assets/com.ivanmurzak.unity.mcp.particlesystem-*.tgz
287425 env :
288426 GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
289427
290- publish-unity-installer :
428+ # Cleanup job to remove build artifacts after the atomic publish.
429+ cleanup-artifacts :
291430 runs-on : ubuntu-latest
292- needs : release-unity-plugin
293- if : needs.release-unity-plugin.outputs.success == 'true'
431+ needs : [ release-unity-plugin]
432+ if : always()
294433 steps :
295- - name : Download Unity Package artifact
296- uses : actions/download -artifact@v6
434+ - name : Delete Unity Package artifacts
435+ uses : geekyeggo/delete -artifact@v5
297436 with :
298437 name : unity-installer-package
299- path : ./
438+ failOnError : false
439+ continue-on-error : true
300440
301- - name : Upload Unity Package to Release
302- uses : softprops/action-gh-release@v2
441+ - name : Delete signed UPM package artifacts
442+ uses : geekyeggo/delete-artifact@v5
303443 with :
304- files : ./AI-ParticleSystem-Installer.unitypackage
305- tag_name : ${{ needs.release-unity-plugin.outputs.version }}
306- env :
307- GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
444+ name : signed-upm-package
445+ failOnError : false
446+ continue-on-error : true
308447
309- # Cleanup job to remove build artifacts after publishing
310- cleanup-artifacts :
311- runs-on : ubuntu-latest
312- needs : [publish-unity-installer]
313- if : always()
314- steps :
315- - name : Delete Unity Package artifacts
448+ - name : Delete release notes artifacts
316449 uses : geekyeggo/delete-artifact@v5
317450 with :
318- name : unity-installer-package
451+ name : release-notes
319452 failOnError : false
320453 continue-on-error : true
0 commit comments