diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d68ebf9..581f240 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,65 @@ jobs: with: tag: ${{ steps.get_version.outputs.current-version }} + # Generates release notes (release.md) in parallel with tests/builds. + # The artifact is consumed by release-unity-plugin's atomic publish step. + prepare-release-notes: + runs-on: ubuntu-latest + needs: [check-version-tag] + if: needs.check-version-tag.outputs.tag_exists == 'false' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Generate release description + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + version=${{ needs.check-version-tag.outputs.version }} + prev_tag=${{ needs.check-version-tag.outputs.prev_tag }} + repo_url="https://github.com/${GITHUB_REPOSITORY}" + today=$(date +'%B %e, %Y') + + echo "repo_url: $repo_url" + echo "today: $today" + + echo "# Package $version" > release.md + echo "**Released:** *$today*" >> release.md + + echo "" >> release.md + echo "---" >> release.md + echo "" >> release.md + + if [ -n "$prev_tag" ]; then + echo "## Comparison" >> release.md + echo "See every change: [Compare $prev_tag...$version]($repo_url/compare/$prev_tag...$version)" >> release.md + + echo "" >> release.md + echo "---" >> release.md + echo "" >> release.md + + echo "## Commit Summary (Newest → Oldest)" >> release.md + for sha in $(git log --pretty=format:'%H' $prev_tag..HEAD); do + username=$(gh api repos/${GITHUB_REPOSITORY}/commits/$sha --jq '.author.login // .commit.author.name' 2>/dev/null || true) + if [ -z "$username" ]; then + username=$(git log -1 --pretty=format:'%an' $sha) + fi + message=$(git log -1 --pretty=format:'%s' $sha) + short_sha=$(git log -1 --pretty=format:'%h' $sha) + echo "- [\`$short_sha\`]($repo_url/commit/$sha) — $message by @$username" >> release.md + done + fi + + - name: Upload release notes as artifact + uses: actions/upload-artifact@v6 + with: + name: release-notes + path: ./release.md + build-unity-installer: runs-on: ubuntu-latest needs: [check-version-tag] @@ -112,6 +171,99 @@ jobs: name: unity-installer-package path: ./Installer/build/AI-ParticleSystem-Installer.unitypackage + # Builds the signed UPM package (.tgz with package/.attestation.p7m) in parallel + # with tests/builds and uploads it as a `signed-upm-package` artifact for the + # atomic release publish in release-unity-plugin. + # + # HARD-GATE: this job is NOT continue-on-error. Missing UPM signing secrets fail + # fast and the release pipeline halts — no GitHub Release is created without a + # signed UPM tarball. See docs/openupm-signing.md for the blocking-semantics + # rationale. + # + # Required repo secrets (configure via `gh secret set --repo IvanMurzak/Unity-AI-ParticleSystem `): + # - UPM_SERVICE_ACCOUNT_KEY_ID + # - UPM_SERVICE_ACCOUNT_KEY_SECRET + # - UPM_ORG_ID + # See https://openupm.com/docs/signing-upm-packages.html for the procedure. + build-signed-upm-package: + runs-on: ubuntu-latest + needs: [check-version-tag] + if: needs.check-version-tag.outputs.tag_exists == 'false' + env: + UPM_SERVICE_ACCOUNT_KEY_ID: ${{ secrets.UPM_SERVICE_ACCOUNT_KEY_ID }} + UPM_SERVICE_ACCOUNT_KEY_SECRET: ${{ secrets.UPM_SERVICE_ACCOUNT_KEY_SECRET }} + UPM_ORG_ID: ${{ secrets.UPM_ORG_ID }} + PACKAGE_DIR: Unity-Package/Assets/root + DIST_DIR: /tmp/signed-upm-dist + steps: + - name: Verify signing secrets are configured + run: | + missing=() + [ -z "$UPM_SERVICE_ACCOUNT_KEY_ID" ] && missing+=("UPM_SERVICE_ACCOUNT_KEY_ID") + [ -z "$UPM_SERVICE_ACCOUNT_KEY_SECRET" ] && missing+=("UPM_SERVICE_ACCOUNT_KEY_SECRET") + [ -z "$UPM_ORG_ID" ] && missing+=("UPM_ORG_ID") + if [ "${#missing[@]}" -ne 0 ]; then + 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[*]}" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Log package metadata + run: | + package_name="$(jq -r '.name' "$PACKAGE_DIR/package.json")" + package_version="$(jq -r '.version' "$PACKAGE_DIR/package.json")" + + printf 'Package name: %s\n' "$package_name" + printf 'Package version: %s\n' "$package_version" + + - name: Install Unity UPM CLI + run: | + curl -fsSL https://cdn.packages.unity.com/upm-cli/install.sh -o install.sh + bash install.sh + echo "$HOME/.upm/bin" >> "$GITHUB_PATH" + + - name: Verify Unity UPM CLI + run: upm --help + + - name: Sign package + run: | + mkdir -p "$DIST_DIR" + upm pack "./$PACKAGE_DIR" --organization-id "$UPM_ORG_ID" --destination "$DIST_DIR" + + - name: Verify signed package contains attestation + run: | + shopt -s nullglob + archives=("$DIST_DIR"/*.tgz "$DIST_DIR"/*.tar.gz) + if [ "${#archives[@]}" -ne 1 ]; then + printf 'Expected exactly one signed package archive, found %s: %s\n' "${#archives[@]}" "${archives[*]:-}" >&2 + exit 1 + fi + + archive="${archives[0]}" + archive_basename="$(basename "$archive")" + # OpenUPM consumes the asset via the `githubReleaseAssetName: 'com.ivanmurzak.unity.mcp.particlesystem-'` + # prefix documented in docs/openupm-signing.md. Enforce the contract here so a future + # `upm pack` naming change fails CI loudly instead of silently breaking OpenUPM pickup. + if [[ "$archive_basename" != com.ivanmurzak.unity.mcp.particlesystem-* ]]; then + 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 + exit 1 + fi + + archive_entries="$(tar -tzf "$archive")" + grep -qx 'package/package.json' <<<"$archive_entries" + grep -qx 'package/.attestation.p7m' <<<"$archive_entries" + + printf 'Signed archive: %s\n' "$archive_basename" + tar -xOzf "$archive" package/package.json | jq '{name, version}' + + - name: Upload signed UPM package as artifact + uses: actions/upload-artifact@v6 + with: + name: signed-upm-package + path: /tmp/signed-upm-dist/*.tgz + # --- UNITY TESTS --- # ------------------- @@ -204,12 +356,19 @@ jobs: # ------------------- + # Atomic publish point — gated on EVERY prerequisite (tests, installer build, + # signed UPM package, release notes). Downloads all asset artifacts and creates + # the GitHub Release + tag with the full asset set in a SINGLE + # softprops/action-gh-release@v2 call so a failed upload cannot strand the + # release with incomplete assets. Signing failure → no release (hard gate). release-unity-plugin: runs-on: ubuntu-latest needs: [ check-version-tag, + prepare-release-notes, build-unity-installer, + build-signed-upm-package, test-unity-2022-3-62f3-editmode, test-unity-2022-3-62f3-playmode, test-unity-2022-3-62f3-standalone, @@ -223,98 +382,72 @@ jobs: if: needs.check-version-tag.outputs.tag_exists == 'false' outputs: version: ${{ needs.check-version-tag.outputs.version }} - success: ${{ steps.rel_desc.outputs.success }} - release_notes: ${{ steps.rel_desc.outputs.release_body }} steps: - - name: Checkout repository - uses: actions/checkout@v6 + - name: Download release notes artifact + uses: actions/download-artifact@v6 with: - fetch-depth: 0 - fetch-tags: true - - - name: Generate release description - id: rel_desc - env: - GH_TOKEN: ${{ github.token }} - run: | - set -e - version=${{ needs.check-version-tag.outputs.version }} - prev_tag=${{ needs.check-version-tag.outputs.prev_tag }} - repo_url="https://github.com/${GITHUB_REPOSITORY}" - today=$(date +'%B %e, %Y') - - echo "repo_url: $repo_url" - echo "today: $today" + name: release-notes + path: ./release-notes - echo "# Package $version" > release.md - echo "**Released:** *$today*" >> release.md - - echo "" >> release.md - echo "---" >> release.md - echo "" >> release.md - - if [ -n "$prev_tag" ]; then - echo "## Comparison" >> release.md - echo "See every change: [Compare $prev_tag...$version]($repo_url/compare/$prev_tag...$version)" >> release.md - - echo "" >> release.md - echo "---" >> release.md - echo "" >> release.md + - name: Download Unity installer artifact + uses: actions/download-artifact@v6 + with: + name: unity-installer-package + path: ./assets - echo "## Commit Summary (Newest → Oldest)" >> release.md - for sha in $(git log --pretty=format:'%H' $prev_tag..HEAD); do - username=$(gh api repos/${GITHUB_REPOSITORY}/commits/$sha --jq '.author.login // .commit.author.name' 2>/dev/null || true) - if [ -z "$username" ]; then - username=$(git log -1 --pretty=format:'%an' $sha) - fi - message=$(git log -1 --pretty=format:'%s' $sha) - short_sha=$(git log -1 --pretty=format:'%h' $sha) - echo "- [\`$short_sha\`]($repo_url/commit/$sha) — $message by @$username" >> release.md - done - fi + - name: Download signed UPM package artifact + uses: actions/download-artifact@v6 + with: + name: signed-upm-package + path: ./assets - printf "release_body<> $GITHUB_OUTPUT - echo "success=true" >> $GITHUB_OUTPUT + - name: List assembled release assets + run: | + set -e + echo "Release notes:" + ls -la ./release-notes + echo "" + echo "Release assets:" + ls -la ./assets - - name: Create Tag and Release + - name: Create Tag and Release with all assets uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.check-version-tag.outputs.version }} name: ${{ needs.check-version-tag.outputs.version }} - body: ${{ steps.rel_desc.outputs.release_body }} + body_path: ./release-notes/release.md draft: false prerelease: false + fail_on_unmatched_files: true + files: | + ./assets/AI-ParticleSystem-Installer.unitypackage + ./assets/com.ivanmurzak.unity.mcp.particlesystem-*.tgz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - publish-unity-installer: + # Cleanup job to remove build artifacts after the atomic publish. + cleanup-artifacts: runs-on: ubuntu-latest - needs: release-unity-plugin - if: needs.release-unity-plugin.outputs.success == 'true' + needs: [release-unity-plugin] + if: always() steps: - - name: Download Unity Package artifact - uses: actions/download-artifact@v6 + - name: Delete Unity Package artifacts + uses: geekyeggo/delete-artifact@v5 with: name: unity-installer-package - path: ./ + failOnError: false + continue-on-error: true - - name: Upload Unity Package to Release - uses: softprops/action-gh-release@v2 + - name: Delete signed UPM package artifacts + uses: geekyeggo/delete-artifact@v5 with: - files: ./AI-ParticleSystem-Installer.unitypackage - tag_name: ${{ needs.release-unity-plugin.outputs.version }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + name: signed-upm-package + failOnError: false + continue-on-error: true - # Cleanup job to remove build artifacts after publishing - cleanup-artifacts: - runs-on: ubuntu-latest - needs: [publish-unity-installer] - if: always() - steps: - - name: Delete Unity Package artifacts + - name: Delete release notes artifacts uses: geekyeggo/delete-artifact@v5 with: - name: unity-installer-package + name: release-notes failOnError: false continue-on-error: true diff --git a/docs/openupm-signing.md b/docs/openupm-signing.md new file mode 100644 index 0000000..a710cdb --- /dev/null +++ b/docs/openupm-signing.md @@ -0,0 +1,159 @@ +# OpenUPM Package Signing + +Unity 6.3 introduced a package-signature check that surfaces a trust warning for +unsigned UPM packages installed from third-party registries (including OpenUPM). +This document describes how `IvanMurzak/Unity-AI-ParticleSystem` signs its +`com.ivanmurzak.unity.mcp.particlesystem` package so the warning no longer +appears in Unity 6.3+. + +## How signing works + +OpenUPM does **not** sign packages on behalf of authors — each package author runs +the signing flow in their own CI using a Unity organization's service account. The +signed `.tgz` is uploaded as a GitHub Release asset, and OpenUPM picks it up when +the package's listing has `trackingMode: githubRelease`. + +References: +- +- +- Reference workflow / repo layout: + +## What this repo ships + +The signing step is implemented as the `build-signed-upm-package` job in +[`.github/workflows/release.yml`](../.github/workflows/release.yml). It runs in +parallel with tests and builds on every version-bump release commit, packs the +package at `Unity-Package/Assets/root/` with Unity's UPM CLI, verifies the +resulting archive contains `package/.attestation.p7m` and that its basename begins +with `com.ivanmurzak.unity.mcp.particlesystem-`, and uploads the signed `.tgz` as +a `signed-upm-package` workflow artifact. + +The artifact is then consumed by the atomic publish step in `release-unity-plugin`, +which downloads every release asset (the `.unitypackage` and the signed `.tgz`) +and creates the GitHub Release + tag with all assets attached in a single +`softprops/action-gh-release@v2` call. There are no separate post-release publish +jobs — the release is created in a single step after all prerequisites pass; if +any prerequisite fails, no release is created. The `fail_on_unmatched_files: true` +option on the release action ensures the step hard-fails (rather than silently +publishing) if any of the asset globs match zero files. + +### Signing is a hard gate on the release + +`build-signed-upm-package` is **not** `continue-on-error`. If the three required +repo secrets (see below) are missing, or if `upm pack` / attestation verification +fails for any reason, the job exits non-zero, the release-creation job does not +run, and **no GitHub Release is created**. This is intentional: every public +release must ship the signed UPM tarball so OpenUPM (with the listing on +`trackingMode: githubRelease`) can surface the signed package without ever +race-publishing an unsigned git tag. + +If you need to ship a release without signing, the correct action is to land a +follow-up PR that explicitly removes the gate — not to silently skip signing. + +## One-time setup (repository owner) + +These steps are operational, not code changes. The release pipeline cannot ship +a release until they are complete. + +### 1. Create a Unity organization service account + +A Unity organization is required to obtain UPM signing credentials (the +individual / personal Unity license cannot sign packages). + +1. Go to the [Unity Cloud Dashboard](https://cloud.unity.com/) and either create + an organization or use an existing one you own. +2. Inside the organization settings, create a service account dedicated to + package signing. +3. Grant the service account the **package signing** permission for the + organization. +4. Generate a service-account key — record the `Key ID`, the `Key Secret`, and + the organization's `Org ID`. The secret is shown only once. + +### 2. Add the three GitHub repository secrets + +In this repo's Settings → Secrets and variables → Actions, add: + +| Secret name | Value | +| --------------------------------- | ------------------------------------ | +| `UPM_SERVICE_ACCOUNT_KEY_ID` | Service account key ID | +| `UPM_SERVICE_ACCOUNT_KEY_SECRET` | Service account key secret | +| `UPM_ORG_ID` | Unity organization ID | + +CLI equivalent: + +```bash +gh secret set UPM_SERVICE_ACCOUNT_KEY_ID --repo IvanMurzak/Unity-AI-ParticleSystem +gh secret set UPM_SERVICE_ACCOUNT_KEY_SECRET --repo IvanMurzak/Unity-AI-ParticleSystem +gh secret set UPM_ORG_ID --repo IvanMurzak/Unity-AI-ParticleSystem +``` + +### 3. File the OpenUPM listing change + +OpenUPM's package listing for `com.ivanmurzak.unity.mcp.particlesystem` currently +has `trackingMode: git`, which makes OpenUPM pack and serve unsigned tarballs from +the repository's git tags. To make OpenUPM serve the signed tarball that the +workflow now uploads, the listing must be flipped to `trackingMode: githubRelease`. + +The listing lives in the [openupm/openupm](https://github.com/openupm/openupm) +repository at `data/packages/com.ivanmurzak.unity.mcp.particlesystem.yml`. Open a +PR there changing: + +```yaml +trackingMode: git +``` + +to: + +```yaml +trackingMode: githubRelease +``` + +Per the OpenUPM blog, switch `trackingMode` to `githubRelease` **before** the +first signed release ships, so OpenUPM does not race-publish the unsigned git +tag in parallel. + +Also set `githubReleaseAssetName` so OpenUPM picks the signed tarball by +filename prefix rather than guessing from the asset list. The release also ships +the `.unitypackage` installer asset, so the prefix guard prevents a future +breaking failure mode: + +```yaml +githubReleaseAssetName: 'com.ivanmurzak.unity.mcp.particlesystem-' +``` + +## Verifying signing worked + +After the next release ships: + +1. Go to the + [release page](https://github.com/IvanMurzak/Unity-AI-ParticleSystem/releases) + for the new version and confirm a + `com.ivanmurzak.unity.mcp.particlesystem-.tgz` asset is attached + alongside the `.unitypackage`. The single-step publish runs only after the + signed tarball is built and verified, so a successful release run should always + include the signed asset. +2. Inspect the tarball locally to confirm it contains the signing attestation: + + ```bash + curl -fsSL -o package.tgz \ + https://github.com/IvanMurzak/Unity-AI-ParticleSystem/releases/download//com.ivanmurzak.unity.mcp.particlesystem-.tgz + tar -tzf package.tgz | grep '\.attestation\.p7m$' + # expected: package/.attestation.p7m + ``` + +3. Once the OpenUPM listing change merges, install the package in Unity 6.3+ + from OpenUPM and confirm the unsigned-package warning no longer appears. + +## Troubleshooting + +- **`build-signed-upm-package` fails with `UPM signing secrets are not configured`** — + the three repo secrets above have not been set (or were set on the wrong repo). + Complete the "One-time setup" steps above. The release pipeline is hard-gated + on these secrets; until they are configured no release will ship. +- **`upm pack` fails with an authentication error** — the service account key + is invalid or lacks the package-signing permission. Regenerate the key in the + Unity org dashboard and re-set the GitHub secrets. +- **The release contains the `.tgz` but Unity 6.3 still shows the warning** — + the OpenUPM listing is still on `trackingMode: git` (OpenUPM is serving the + unsigned git-packed version, not the release asset). File the + `openupm/openupm` PR described above.