Skip to content

Commit 5217df3

Browse files
IvanMurzakclaude
andauthored
ci: atomic release publish hard-gated on OpenUPM signing (#16)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6f694fe commit 5217df3

2 files changed

Lines changed: 361 additions & 69 deletions

File tree

.github/workflows/release.yml

Lines changed: 202 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,65 @@ jobs:
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

Comments
 (0)