From c81dbb0127cb746c29078e31c4e95ac1c4b74656 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 23:06:07 +0000 Subject: [PATCH 1/6] feat(desktop): wire macOS Developer ID signing + notarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add gated code-signing + notarization for the macOS desktop DMG so it can clear Gatekeeper and stay in Homebrew's main cask (unsigned casks are rejected after 2026-09-01). - desktopApp/build.gradle.kts: macOS signing{}/notarization{} blocks, gated on the AMETHYST_MAC_SIGN_IDENTITY env var. Absent => unsigned DMG, exactly as before, so local dev and PR CI are unaffected. - create-release.yml: import a Developer ID cert into a throwaway keychain on the macOS leg and export the signing/notary env. Soft-gated on the MAC_CERTIFICATE_P12 secret — no secret => unsigned build. - BUILDING.md: document the six MAC_* secrets, how to generate them, and flip the unsigned-cask fallback note to reflect the wiring is now in place (pending Apple credentials). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD --- .github/workflows/create-release.yml | 44 ++++++++++++++++++++++++++++ BUILDING.md | 39 ++++++++++++++++++++---- desktopApp/build.gradle.kts | 30 +++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 83547b56e1..4955866d9b 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -101,8 +101,52 @@ jobs: fi chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage + # macOS only: import the Developer ID Application cert into a throwaway + # keychain so jpackage's codesign pass can find it. Soft-gated — if the + # MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are + # provisioned) the DMG is built UNSIGNED, exactly as before. notarytool + # runs as part of the gradle task when the identity env is exported below. + - name: Import Apple Developer ID certificate (macOS leg, if configured) + if: matrix.family == 'macos' + id: mac_keychain + env: + CERT_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} + CERT_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "${CERT_P12:-}" ]]; then + echo "::notice::MAC_CERTIFICATE_P12 not set — building UNSIGNED DMG." + echo "signing=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + KEYCHAIN="$RUNNER_TEMP/amethyst-signing.keychain-db" + KEYCHAIN_PWD="$(openssl rand -base64 24)" + CERT_PATH="$RUNNER_TEMP/developer_id.p12" + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + echo "$CERT_P12" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$CERT_PASSWORD" \ + -k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/productsign + # Let codesign use the private key without an interactive UI prompt. + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null + # Prepend our keychain to the user search list so codesign sees it. + security list-keychains -d user -s "$KEYCHAIN" \ + $(security list-keychains -d user | sed -e 's/[\"[:space:]]//g') + rm -f "$CERT_PATH" + echo "signing=true" >> "$GITHUB_OUTPUT" + - name: Build desktop artifacts uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + env: + # Empty on non-macOS legs and on the macOS leg when no cert is + # configured — the gradle macOS{} block skips signing when the + # identity is blank. + AMETHYST_MAC_SIGN_IDENTITY: ${{ steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_SIGN_IDENTITY || '' }} + AMETHYST_NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }} + AMETHYST_NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }} + AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }} with: max_attempts: 2 timeout_minutes: 15 diff --git a/BUILDING.md b/BUILDING.md index 941bf4b3f1..83cb4b655e 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -250,13 +250,26 @@ provided automatically; everything else you set yourself.) | `SONATYPE_PASSWORD` | Maven Central user token password | Same | | `SIGNING_PRIVATE_KEY` | **GPG/PGP** private key, ASCII-armored | Signs the Maven artifacts (Central requires it) | | `SIGNING_PASSWORD` | Passphrase for that GPG key | Same | +| `MAC_CERTIFICATE_P12` | Base64 of your **Apple Developer ID Application** cert (`.p12`, includes the private key) | Signs the macOS desktop **DMG** | +| `MAC_CERTIFICATE_PASSWORD` | Password set when exporting the `.p12` | Imports the cert into the CI keychain | +| `MAC_SIGN_IDENTITY` | Full identity string, e.g. `Developer ID Application: Your Name (TEAMID)` | The `codesign` identity to sign with | +| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) | +| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same | +| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same | | `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) | | `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) | | `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) | -Note the **two distinct signing identities** people often conflate: +Note the **three distinct signing identities** people often conflate: `SIGNING_KEY` + `KEY_*` is the **Android keystore**; `SIGNING_PRIVATE_KEY` + -`SIGNING_PASSWORD` is the **GPG key** for Maven Central. They are unrelated. +`SIGNING_PASSWORD` is the **GPG key** for Maven Central; `MAC_CERTIFICATE_*` + +`MAC_SIGN_IDENTITY` + `MAC_NOTARY_*` is the **Apple Developer ID** for the macOS +desktop DMG. They are unrelated — each comes from a different authority. + +The macOS desktop signing secrets are **optional**: if `MAC_CERTIFICATE_P12` is +unset the release workflow still builds the DMG, just **unsigned** (the previous +behavior). Provision all six to switch signing + notarization on. Obtaining them +requires Apple Developer Program membership ($99/yr). Generating the values: @@ -269,6 +282,14 @@ base64 -i upload.jks | tr -d '\n' # paste output into SIGNING_KEY # GPG key → armored private key for SIGNING_PRIVATE_KEY gpg --full-generate-key # create the key (once) gpg --armor --export-secret-keys # paste output into SIGNING_PRIVATE_KEY + +# Apple Developer ID Application cert → base64 for MAC_CERTIFICATE_P12. +# In Keychain Access, export the "Developer ID Application: ..." cert (with its +# private key) as a .p12, setting an export password (-> MAC_CERTIFICATE_PASSWORD). +base64 -i developer_id.p12 | tr -d '\n' # paste output into MAC_CERTIFICATE_P12 +security find-identity -v -p codesigning # shows the exact MAC_SIGN_IDENTITY string +# MAC_NOTARY_PASSWORD is an app-specific password from https://appleid.apple.com +# (Sign-In and Security -> App-Specific Passwords), NOT your Apple ID login. ``` `SONATYPE_USERNAME`/`SONATYPE_PASSWORD` are a **user token** from @@ -480,9 +501,17 @@ for the deprecation date. When it hits: Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask` on 2026-09-01. Before that date: -**Option A**: Commit budget to Apple Developer Program ($99/yr), add -`signing { sign.set(true) }` + `notarization {}` blocks to -`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI. +**Option A (wiring done — needs Apple creds)**: The `signing { sign.set(true) }` ++ `notarization {}` blocks are already in `desktopApp/build.gradle.kts` (gated on +the `AMETHYST_MAC_SIGN_IDENTITY` env var), and the macOS leg of +`create-release.yml` imports a Developer ID cert into a throwaway keychain and +exports the signing/notary env. It all stays a **no-op until the six +`MAC_*`/notary secrets are provisioned** (see [§ Secrets the CI +needs](#secrets-the-ci-needs)) — until then the DMG builds unsigned. To turn it +on: join the Apple Developer Program ($99/yr), create a *Developer ID +Application* certificate, generate an app-specific password, and set the six +secrets. The first signed+notarized DMG is best validated with a +`workflow_dispatch` dry-run before a real tag. **Option B**: Pivot to a private Homebrew tap: diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 730ab5c59f..d8cf817781 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -135,6 +135,36 @@ compose.desktop { macOS { bundleID = "com.vitorpamplona.amethyst.desktop" iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + + // --- Developer ID code signing + notarization --- + // Required for Homebrew's main cask (unsigned casks rejected after + // 2026-09-01) and to clear macOS Gatekeeper without the right-click + // dance. Gated on the signing-identity env var so local dev builds + // and PR CI keep producing plain UNSIGNED DMGs exactly as before — + // signing only kicks in when the release workflow exports these + // (which it does only when the Apple secrets are present): + // + // AMETHYST_MAC_SIGN_IDENTITY "Developer ID Application: NAME (TEAMID)" + // AMETHYST_NOTARY_APPLE_ID Apple ID email of the notary account + // AMETHYST_NOTARY_PASSWORD app-specific password for that Apple ID + // AMETHYST_NOTARY_TEAM_ID 10-char Apple Developer Team ID + // + // The Developer ID Application certificate must already be in the + // build host's keychain (CI imports it from a base64 .p12 secret). + // Compose ships default hardened-runtime entitlements that permit + // the JVM's JIT, so no custom entitlements file is needed. + val macSignIdentity = System.getenv("AMETHYST_MAC_SIGN_IDENTITY") + if (!macSignIdentity.isNullOrBlank()) { + signing { + sign.set(true) + identity.set(macSignIdentity) + } + notarization { + appleID.set(System.getenv("AMETHYST_NOTARY_APPLE_ID")) + password.set(System.getenv("AMETHYST_NOTARY_PASSWORD")) + teamID.set(System.getenv("AMETHYST_NOTARY_TEAM_ID")) + } + } } windows { From f24fc1021290750e2df8dcb26cf637fd9bd79261 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:14:09 +0000 Subject: [PATCH 2/6] feat(cli): publish no-JRE jar bundle + Homebrew-core formula for amy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable distributing the `amy` CLI via Homebrew-core (mainline formulae). Homebrew-core builds in a network-sandboxed env, so a from-source Gradle build can't resolve Maven deps there; the accepted pattern for JVM tools is a pre-built no-JRE jar bundle + `depends_on "openjdk"`. installDist already produces exactly that (bin/amy + lib/*.jar, no bundled runtime). - create-release.yml: publish `amy--jvm.tar.gz` (the installDist tree) as a release asset on the linux leg. Pure JVM bytecode, so one platform-independent artifact serves every OS. - packaging/homebrew/amy.rb: reference formula (depends_on openjdk, livecheck for BrewTestBot auto-bumps, `amy --help` smoke test). Not consumed by any build here — it's the artifact to submit to Homebrew/homebrew-core. - BUILDING.md: homebrew-core submission runbook; note that the desktop app is already on mainline Homebrew (homebrew/cask); document name-collision and pre-built-jar review caveats. - asset-name.sh: document the jvm bundle naming exception. Verified locally: :cli:installDist builds with only a JDK (no Android SDK), and the extracted bundle runs via JAVA_HOME (`amy --help` exits 0). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD --- .github/workflows/create-release.yml | 17 ++++++++++ BUILDING.md | 45 ++++++++++++++++++++++++++ packaging/homebrew/amy.rb | 47 ++++++++++++++++++++++++++++ scripts/asset-name.sh | 7 +++++ 4 files changed, 116 insertions(+) create mode 100644 packaging/homebrew/amy.rb diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 4955866d9b..f5a1f0d97d 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -321,6 +321,23 @@ jobs: source scripts/asset-name.sh collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist + # Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it + # cannot use the jlink tarball above (bundled runtime) nor build from + # source (its sandbox blocks Gradle's Maven downloads). installDist + # (bin/amy + lib/*.jar, no runtime/) is exactly that bundle. It is pure + # JVM bytecode, so one platform-independent asset serves every OS; we cut + # it on the linux leg only. amyImage depends on installDist, so the + # cli/build/install/amy tree already exists here. + - name: Package no-JRE jvm bundle for Homebrew (linux leg only) + if: matrix.family == 'linux' + run: | + set -euo pipefail + VER="${{ steps.ver.outputs.version }}" + SRC="cli/build/install/amy" + test -x "$SRC/bin/amy" + ( cd "$SRC" && tar czf "$OLDPWD/dist/amy-${VER}-jvm.tar.gz" bin lib ) + echo "Collected: dist/amy-${VER}-jvm.tar.gz" + - name: Enforce CLI size budget (200 MB per asset) run: | set -euo pipefail diff --git a/BUILDING.md b/BUILDING.md index 83cb4b655e..53bc5e3e3c 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -360,6 +360,51 @@ The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a tiling window manager). After the first PR is merged, `bump-homebrew.yml` auto-submits new version bumps on each stable release. +> **The desktop app is already on mainline Homebrew.** `homebrew/cask` *is* the +> mainline cask repo — GUI apps live in homebrew-**cask**, CLIs in +> homebrew-**core**; both are "mainline." A private tap is only the *fallback* +> if Homebrew ever rejects the (now signed + notarized) cask. + +### Homebrew-core formula for the `amy` CLI (one-time initial PR) + +The CLI goes to **homebrew-core** (mainline formulae), not homebrew-cask — +casks are for GUI apps. homebrew-core builds in a **network-sandboxed** +environment, so a from-source Gradle build can't resolve its Maven +dependencies there. Instead the formula downloads the pre-built **no-JRE jar +bundle** `amy--jvm.tar.gz` (published by `create-release.yml`) and +`depends_on "openjdk"`. The reference formula lives at +[`packaging/homebrew/amy.rb`](packaging/homebrew/amy.rb). + +To submit: + +```bash +# 1. Grab the published asset's sha256 +curl -fsSL -o amy-jvm.tar.gz \ + https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz +shasum -a 256 amy-jvm.tar.gz + +# 2. Fill the url + sha256 into packaging/homebrew/amy.rb, then open the PR +brew create --set-name amy --tap homebrew/core \ + https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz +# (paste the reference formula body, run `brew audit --new amy`, +# `brew install --build-from-source amy`, `brew test amy`, then PR it.) +``` + +Caveats that the maintainer must weigh before submitting: + +- **Name collision.** `amy` may already exist in homebrew-core — check with + `brew search amy` first. If taken, fall back to `amethyst-cli`. +- **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading + a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to + justify it (sandboxed Gradle can't fetch Maven deps). +- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks + Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons` + core/ui split) would shrink it and smooth review — tracked as a follow-up. + +After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot +auto-open version-bump PRs on each stable release — no token or workflow on our +side (unlike the cask/winget bumps). + ### Winget (one-time initial submission) ```bash diff --git a/packaging/homebrew/amy.rb b/packaging/homebrew/amy.rb new file mode 100644 index 0000000000..dc1526a931 --- /dev/null +++ b/packaging/homebrew/amy.rb @@ -0,0 +1,47 @@ +# Reference Homebrew formula for `amy`, the Amethyst CLI. +# +# This file is NOT consumed by any build in this repo. It is the artifact you +# submit to Homebrew/homebrew-core (`brew bump-formula-pr` / a new-formula PR). +# Once accepted, homebrew-core's copy is the source of truth; keep this in sync +# for reference and to make version bumps a copy-paste. +# +# Why a pre-built jar bundle instead of building from source: +# homebrew-core builds inside a network sandbox, so a Gradle build cannot +# resolve its Maven dependencies there. The accepted pattern for JVM tools is +# to download a pre-built, no-JRE jar bundle and depend on the system openjdk. +# We publish exactly that as `amy--jvm.tar.gz` (bin/amy + lib/*.jar, +# no bundled runtime) from .github/workflows/create-release.yml. +# +# Before submitting: replace the version in the url and the sha256 with the +# values for the actual published release asset: +# curl -fsSL -o amy-jvm.tar.gz \ +# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amy-X.Y.Z-jvm.tar.gz +# shasum -a 256 amy-jvm.tar.gz +class Amy < Formula + desc "Command-line Nostr client from the Amethyst project" + homepage "https://github.com/vitorpamplona/amethyst" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz" + sha256 "REPLACE_WITH_RELEASE_ASSET_SHA256" + license "MIT" + + # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new + # stable GitHub release appears. + livecheck do + url :stable + strategy :github_latest + end + + depends_on "openjdk" + + def install + # Tarball top level is bin/ and lib/ (the Gradle installDist layout). + libexec.install Dir["*"] + # Wrapper on PATH that pins JAVA_HOME to Homebrew's openjdk so amy runs + # regardless of the user's own Java setup. + (bin/"amy").write_env_script libexec/"bin/amy", JAVA_HOME: Formula["openjdk"].opt_prefix + end + + test do + assert_match "Amethyst command-line interface", shell_output("#{bin}/amy --help 2>&1") + end +end diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index aac7b3147f..6e82288085 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -32,6 +32,13 @@ # amy-1.08.0-linux-x64.tar.gz # amy-1.08.0-linux-x64.deb # amy-1.08.0-linux-x64.rpm +# +# One CLI asset breaks the family/arch shape on purpose: the no-JRE jar bundle +# for Homebrew-core is pure JVM bytecode (no bundled runtime), so a single +# platform-independent artifact serves every OS: +# amy-1.08.0-jvm.tar.gz +# It is packaged inline in create-release.yml (linux leg), not via the helpers +# below, since it has no / dimension. set -euo pipefail From d10e6c4d814e6349374380232b158ad69ea264e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:21:58 +0000 Subject: [PATCH 3/6] feat(cli): codesign + notarize the macOS amy tarball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign the macOS jlink image (amy--macos-arm64.tar.gz) so users who download it directly clear Gatekeeper. Reuses the same Developer ID cert and the six MAC_* secrets as the desktop DMG; no-op when they're absent. - .github/actions/import-macos-cert: factor the throwaway-keychain cert import into a composite action; the desktop leg now uses it too (was inline). - create-release.yml (build-cli macOS leg): import the cert, then codesign every Mach-O binary in the bundled JRE (executables get hardened-runtime entitlements, dylibs don't) and notarize via notarytool --wait. Runs before the collect step so the tarred image is signed. Job timeout 30->45 min for notarization headroom. - cli/packaging/macos/amy.entitlements: hardened-runtime entitlements; the disable-library-validation key lets the JVM load the secp256k1 native dylib it extracts from a jar at runtime (would otherwise crash under notarization). - BUILDING.md: document the tarball signing, the no-stapling/online-check caveat, and that the Homebrew-core jvm bundle is intentionally left unsigned. Untested end-to-end (no macOS runner / Apple creds here) — validate with a workflow_dispatch dry-run once the secrets are provisioned. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD --- .github/actions/import-macos-cert/action.yml | 52 +++++++++++++ .github/workflows/create-release.yml | 81 +++++++++++++------- BUILDING.md | 20 +++-- cli/packaging/macos/amy.entitlements | 26 +++++++ 4 files changed, 145 insertions(+), 34 deletions(-) create mode 100644 .github/actions/import-macos-cert/action.yml create mode 100644 cli/packaging/macos/amy.entitlements diff --git a/.github/actions/import-macos-cert/action.yml b/.github/actions/import-macos-cert/action.yml new file mode 100644 index 0000000000..e9ecb02761 --- /dev/null +++ b/.github/actions/import-macos-cert/action.yml @@ -0,0 +1,52 @@ +name: Import macOS Developer ID certificate +description: > + Import a Developer ID Application certificate (base64-encoded .p12) into a + throwaway keychain so codesign/jpackage can find it during the job. Soft: + when no certificate is supplied it is a no-op and reports signing=false, so + callers build UNSIGNED artifacts exactly as before. + +inputs: + certificate-p12-base64: + description: Base64 of the Developer ID Application .p12 (cert + private key) + required: true + certificate-password: + description: Password used when the .p12 was exported + required: true + +outputs: + signing: + description: "'true' if a certificate was imported, else 'false'" + value: ${{ steps.import.outputs.signing }} + +runs: + using: composite + steps: + - id: import + shell: bash + env: + CERT_P12: ${{ inputs.certificate-p12-base64 }} + CERT_PASSWORD: ${{ inputs.certificate-password }} + run: | + set -euo pipefail + if [[ -z "${CERT_P12:-}" ]]; then + echo "::notice::No macOS signing certificate configured — artifacts will be UNSIGNED." + echo "signing=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + KEYCHAIN="$RUNNER_TEMP/amethyst-signing.keychain-db" + KEYCHAIN_PWD="$(openssl rand -base64 24)" + CERT_PATH="$RUNNER_TEMP/developer_id.p12" + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + echo "$CERT_P12" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$CERT_PASSWORD" \ + -k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/productsign + # Let codesign use the private key without an interactive UI prompt. + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null + # Prepend our keychain to the user search list so codesign sees it. + security list-keychains -d user -s "$KEYCHAIN" \ + $(security list-keychains -d user | sed -e 's/[\"[:space:]]//g') + rm -f "$CERT_PATH" + echo "signing=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f5a1f0d97d..7223786d79 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -102,40 +102,17 @@ jobs: chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage # macOS only: import the Developer ID Application cert into a throwaway - # keychain so jpackage's codesign pass can find it. Soft-gated — if the + # keychain so jpackage's codesign pass can find it. Soft — if the # MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are # provisioned) the DMG is built UNSIGNED, exactly as before. notarytool # runs as part of the gradle task when the identity env is exported below. - name: Import Apple Developer ID certificate (macOS leg, if configured) if: matrix.family == 'macos' id: mac_keychain - env: - CERT_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} - CERT_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} - run: | - set -euo pipefail - if [[ -z "${CERT_P12:-}" ]]; then - echo "::notice::MAC_CERTIFICATE_P12 not set — building UNSIGNED DMG." - echo "signing=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - KEYCHAIN="$RUNNER_TEMP/amethyst-signing.keychain-db" - KEYCHAIN_PWD="$(openssl rand -base64 24)" - CERT_PATH="$RUNNER_TEMP/developer_id.p12" - security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" - security set-keychain-settings -lut 21600 "$KEYCHAIN" - security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" - echo "$CERT_P12" | base64 --decode > "$CERT_PATH" - security import "$CERT_PATH" -P "$CERT_PASSWORD" \ - -k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/productsign - # Let codesign use the private key without an interactive UI prompt. - security set-key-partition-list -S apple-tool:,apple:,codesign: \ - -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null - # Prepend our keychain to the user search list so codesign sees it. - security list-keychains -d user -s "$KEYCHAIN" \ - $(security list-keychains -d user | sed -e 's/[\"[:space:]]//g') - rm -f "$CERT_PATH" - echo "signing=true" >> "$GITHUB_OUTPUT" + uses: ./.github/actions/import-macos-cert + with: + certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }} + certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} - name: Build desktop artifacts uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 @@ -256,7 +233,7 @@ jobs: - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: run: shell: bash @@ -305,6 +282,52 @@ jobs: timeout_minutes: 15 command: ./gradlew --no-daemon :cli:${{ matrix.tasks }} + # macOS only: import the Developer ID cert (no-op without the secret) so + # the next step can codesign the jlink image. The jvm bundle for + # Homebrew-core is NOT signed here — Homebrew strips quarantine itself. + - name: Import Apple Developer ID certificate (macOS leg, if configured) + if: matrix.family == 'macos' + id: mac_keychain + uses: ./.github/actions/import-macos-cert + with: + certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }} + certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + + # Codesign + notarize the macOS jlink image (amy--macos-arm64.tar.gz) + # for users who download it directly. A loose tarball can't be stapled + # (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization + # online on first run. Runs before "Collect" so the tarred image is signed. + - name: Sign + notarize amy image (macOS leg, if configured) + if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true' + env: + SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }} + NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }} + NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }} + NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }} + run: | + set -euo pipefail + IMG="cli/build/amy-image/amy" + ENTITLEMENTS="cli/packaging/macos/amy.entitlements" + # Sign every Mach-O binary in the bundled JRE. Each is signed + # independently (no enclosing .app seals them), so order is irrelevant. + # Executables get the hardened-runtime entitlements; dylibs don't. + while IFS= read -r f; do + case "$(file -b "$f")" in + *Mach-O*executable*) + codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;; + *Mach-O*) + codesign --force --options runtime --timestamp \ + --sign "$SIGN_IDENTITY" "$f" ;; + esac + done < <(find "$IMG" -type f) + codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java" + # Notarize: zip the signed image, submit, wait for Apple's verdict. + ditto -c -k --keepParent "$IMG" "$RUNNER_TEMP/amy-notarize.zip" + xcrun notarytool submit "$RUNNER_TEMP/amy-notarize.zip" \ + --apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \ + --team-id "$NOTARY_TEAM_ID" --wait + # jpackage pins libicu to the build host's version (libicu74 on # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. - name: Relax libicu dependency in .deb diff --git a/BUILDING.md b/BUILDING.md index 53bc5e3e3c..aefce7849b 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -250,7 +250,7 @@ provided automatically; everything else you set yourself.) | `SONATYPE_PASSWORD` | Maven Central user token password | Same | | `SIGNING_PRIVATE_KEY` | **GPG/PGP** private key, ASCII-armored | Signs the Maven artifacts (Central requires it) | | `SIGNING_PASSWORD` | Passphrase for that GPG key | Same | -| `MAC_CERTIFICATE_P12` | Base64 of your **Apple Developer ID Application** cert (`.p12`, includes the private key) | Signs the macOS desktop **DMG** | +| `MAC_CERTIFICATE_P12` | Base64 of your **Apple Developer ID Application** cert (`.p12`, includes the private key) | Signs the macOS desktop **DMG** and the macOS **amy** jlink tarball | | `MAC_CERTIFICATE_PASSWORD` | Password set when exporting the `.p12` | Imports the cert into the CI keychain | | `MAC_SIGN_IDENTITY` | Full identity string, e.g. `Developer ID Application: Your Name (TEAMID)` | The `codesign` identity to sign with | | `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) | @@ -266,10 +266,20 @@ Note the **three distinct signing identities** people often conflate: `MAC_SIGN_IDENTITY` + `MAC_NOTARY_*` is the **Apple Developer ID** for the macOS desktop DMG. They are unrelated — each comes from a different authority. -The macOS desktop signing secrets are **optional**: if `MAC_CERTIFICATE_P12` is -unset the release workflow still builds the DMG, just **unsigned** (the previous -behavior). Provision all six to switch signing + notarization on. Obtaining them -requires Apple Developer Program membership ($99/yr). +The macOS signing secrets are **optional**: if `MAC_CERTIFICATE_P12` is unset +the release workflow still builds the DMG **and** the macOS `amy` tarball, just +**unsigned** (the previous behavior). Provision all six to switch signing + +notarization on for both. Obtaining them requires Apple Developer Program +membership ($99/yr). The same one certificate signs both artifacts. + +The macOS `amy` tarball is the jlink image (bundled JRE), so signing it means +codesigning every Mach-O binary in that runtime with hardened-runtime +entitlements (`cli/packaging/macos/amy.entitlements` — needed so the JVM can +load the secp256k1 native library it extracts at runtime). A loose `.tar.gz` +cannot be **stapled** (Apple's `stapler` only handles `.app`/`.dmg`/`.pkg`), so +Gatekeeper verifies notarization **online** on first run — fine for a CLI. +Note the Homebrew-core jvm bundle (`amy--jvm.tar.gz`) is **not** signed: +Homebrew removes the quarantine attribute on its own downloads. Generating the values: diff --git a/cli/packaging/macos/amy.entitlements b/cli/packaging/macos/amy.entitlements new file mode 100644 index 0000000000..08352a00a9 --- /dev/null +++ b/cli/packaging/macos/amy.entitlements @@ -0,0 +1,26 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.allow-dyld-environment-variables + + + From 3c422b18245519917c3df308d2e0d2efe4b08d98 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:49:26 +0000 Subject: [PATCH 4/6] ci(cli): surface notary log on non-Accepted; record signing validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS validation (Developer ID D77MCV9NZ7) confirmed the hardened-runtime entitlements are correct and load-bearing: amy init derives a secp256k1 key cleanly, and dropping disable-library-validation reproduces the runtime dlopen Team-ID failure. The one unverified gap is whether Apple's notary service accepts the unsigned Mach-O dylibs embedded inside lib/*.jar (secp256k1/jna/sqlite/skiko), which it inspects recursively. - create-release.yml: the notarize step now submits with --output-format json, and on any non-Accepted status dumps `notarytool log` (per-file issues) and fails — so the first real run names the offending files instead of failing opaquely. No speculative in-jar signing yet; gather the log first. - BUILDING.md: record the validation result, the embedded-jar-native risk, the one-run way to decide it (workflow_dispatch dry_run with MAC_* secrets), and the staged fixes (sign-in-jar and/or strip the skiko/Compose leak). Note the desktop app shares the same jars and needs its own dry-run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD --- .github/workflows/create-release.yml | 29 ++++++++++++++++++++++++---- BUILDING.md | 22 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 7223786d79..39b6394407 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -323,10 +323,31 @@ jobs: done < <(find "$IMG" -type f) codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java" # Notarize: zip the signed image, submit, wait for Apple's verdict. - ditto -c -k --keepParent "$IMG" "$RUNNER_TEMP/amy-notarize.zip" - xcrun notarytool submit "$RUNNER_TEMP/amy-notarize.zip" \ - --apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \ - --team-id "$NOTARY_TEAM_ID" --wait + # The notary service recursively inspects the lib/*.jar files, so any + # unsigned Mach-O embedded in them (secp256k1/jna/sqlite/skiko natives) + # can come back Invalid. Surface the per-file log so the first real run + # is diagnostic rather than a bare failure. + ZIP="$RUNNER_TEMP/amy-notarize.zip" + OUT="$RUNNER_TEMP/notary-submit.json" + ditto -c -k --keepParent "$IMG" "$ZIP" + if ! xcrun notarytool submit "$ZIP" \ + --apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \ + --team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then + echo "::warning::notarytool submit exited non-zero" + fi + cat "$OUT" + STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)" + SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)" + if [ "$STATUS" != "Accepted" ]; then + echo "::error::Notarization status: $STATUS" + if [ -n "$SUBMISSION_ID" ]; then + echo "----- notary log -----" + xcrun notarytool log "$SUBMISSION_ID" \ + --apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \ + --team-id "$NOTARY_TEAM_ID" || true + fi + exit 1 + fi # jpackage pins libicu to the build host's version (libicu74 on # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. diff --git a/BUILDING.md b/BUILDING.md index aefce7849b..1fcd052bca 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -281,6 +281,28 @@ Gatekeeper verifies notarization **online** on first run — fine for a CLI. Note the Homebrew-core jvm bundle (`amy--jvm.tar.gz`) is **not** signed: Homebrew removes the quarantine attribute on its own downloads. +> **Validated (Developer ID `D77MCV9NZ7`):** signing every Mach-O in the bundled +> JRE with hardened runtime + `amy.entitlements` lets `amy init` derive a key via +> secp256k1 with no library-validation crash. Dropping `disable-library-validation` +> reproduces `UnsatisfiedLinkError: … different Team IDs` on the runtime-extracted +> `libsecp256k1-jni.dylib` — so that entitlement is load-bearing, not decorative. +> +> **Open risk — embedded jar natives.** The notary service unpacks `lib/*.jar` +> recursively and checks every Mach-O for a signature + hardened runtime. Our +> sign loop only touches loose files, so 9 unsigned natives ride along inside +> jars on a macOS build: `secp256k1` (1, required at runtime), `jna` (2), +> `sqlite` (2), and `skiko` (4, dead weight — Compose UI the CLI never renders). +> Whether `notarytool` returns `Accepted` or `Invalid` on these is **unverified** +> (the local validation had no notary creds). **Decide it with one run:** set the +> six `MAC_*` secrets and trigger `create-release.yml` via `workflow_dispatch` +> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and +> now prints the per-file notary log on a non-`Accepted` verdict. If it comes +> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before +> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the +> `:commons` core/ui split the size budget already flags). The **desktop** app +> bundles the same jars through Compose/jpackage notarization, so run a desktop +> dry-run too; its in-jar handling differs and is likewise unverified. + Generating the values: ```bash From cb5498ae4c932e1a20fe87fe84a915358f668faa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 01:39:08 +0000 Subject: [PATCH 5/6] perf(cli): drop Compose UI render stack from the amy runtime image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amy is headless and compiles against zero Compose UI (the Compose deps are `implementation` in :commons, so they never hit the CLI compile classpath), but they still rode the runtime classpath into the shipped image — ~29 MB of Compose desktop render stack, including skiko's native .dylibs that enlarged the macOS notarization surface. Exclude skiko + the org.jetbrains.compose UI groups (ui/foundation/material/ material3/animation) from :cli runtimeClasspath. Keep androidx.compose.runtime (snapshot state + @Stable/@Immutable) — that IS CLI-safe and used by commons models/state. This avoids the commons → commons/commons-ui module split: the single-module, feature-cohesive design (commons/ARCHITECTURE.md §1/§3) is preserved; only the runtime artifact is trimmed. Result: amy image lib 77 MB -> 48 MB (-38%), and all 4 Compose/skiko notary dylibs gone (only secp256k1/jna/sqlite natives remain — the ones actually loaded). A create-release.yml assertion fails the build if the UI stack ever leaks back. Verified with the SDK hidden + an offline amy command battery (init/whoami/ --json/relay/marmot/login, plus a real 6-relay key-package round-trip): zero NoClassDefFoundError/linkage errors; init derives a secp256k1 key cleanly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD --- .github/workflows/create-release.yml | 18 ++++++++++++++++++ cli/build.gradle.kts | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 39b6394407..e923e01ac2 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -282,6 +282,24 @@ jobs: timeout_minutes: 15 command: ./gradlew --no-daemon :cli:${{ matrix.tasks }} + # amy is headless: the Compose UI render stack (skiko + its native dylibs, + # foundation/material/material3/ui/animation) must never reach the CLI + # image. cli/build.gradle.kts excludes it from runtimeClasspath; this + # guards against a transitive dep silently dragging it back (size + macOS + # notarization-surface regression). compose.runtime is CLI-safe and stays. + - name: Assert no Compose UI in the amy image + run: | + set -euo pipefail + LIB="cli/build/install/amy/lib" + leak="$(ls "$LIB" | grep -iE 'skiko|foundation(-layout)?-desktop|material3?-desktop|material-ripple|ui-desktop|animation(-core)?-desktop' || true)" + if [ -n "$leak" ]; then + echo "::error::Compose UI render stack leaked into the amy CLI image:" + echo "$leak" | sed 's/^/ /' + echo "Exclude it in cli/build.gradle.kts (configurations.runtimeClasspath)." + exit 1 + fi + echo "OK: no skiko / Compose UI render jars in the amy image ($(du -sh "$LIB" | cut -f1))." + # macOS only: import the Developer ID cert (no-op without the secret) so # the next step can codesign the jlink image. The jvm bundle for # Homebrew-core is NOT signed here — Homebrew strips quarantine itself. diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 9296e49d5b..5fdf00d2fd 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -30,6 +30,28 @@ dependencies { implementation(libs.slf4j.nop) } +// amy is headless. It compiles against zero Compose UI (the Compose deps are +// `implementation` in :commons, so they never reach the CLI compile classpath — +// verified), yet they still ride the *runtime* classpath into the shipped image: +// ~29 MB of Compose desktop render stack, including skiko's native .dylibs that +// needlessly enlarge the macOS notarization surface. None of it is reachable +// from a CLI command, so drop the whole UI layer from the runtime image. Keep +// `androidx.compose.runtime` — snapshot state + the @Stable/@Immutable +// annotations ARE CLI-safe and used by commons models/state holders (see +// commons/ARCHITECTURE.md §1). A regression here is caught by the "no Compose UI +// in the amy image" assertion in .github/workflows/create-release.yml. +configurations.named("runtimeClasspath") { + // skiko (native renderer + its .dylibs) and the Compose UI bytecode layer. + // NB: Compose Multiplatform publishes UI under org.jetbrains.compose.* — only + // `runtime` relocates to androidx.compose.runtime, which we deliberately keep. + exclude(group = "org.jetbrains.skiko") + exclude(group = "org.jetbrains.compose.ui") + exclude(group = "org.jetbrains.compose.foundation") + exclude(group = "org.jetbrains.compose.material") + exclude(group = "org.jetbrains.compose.material3") + exclude(group = "org.jetbrains.compose.animation") +} + application { mainClass.set("com.vitorpamplona.amethyst.cli.MainKt") applicationName = "amy" From 87c16afe1d2a68dccd13d06193bdd2af8021eb60 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 13:13:43 +0000 Subject: [PATCH 6/6] refactor: move amy.rb into cli/packaging/homebrew (was root packaging/) The Homebrew formula is the cli module's product (amy), and the CLI already owns its packaging artifacts under cli/packaging/ (cli/packaging/macos/ amy.entitlements). The root packaging/ dir was new in this branch and held nothing else, so co-locate the formula with the module that owns it and drop the stray root dir. Updates the two BUILDING.md references. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD --- BUILDING.md | 4 ++-- {packaging => cli/packaging}/homebrew/amy.rb | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename {packaging => cli/packaging}/homebrew/amy.rb (100%) diff --git a/BUILDING.md b/BUILDING.md index 1fcd052bca..3363d10b46 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -405,7 +405,7 @@ environment, so a from-source Gradle build can't resolve its Maven dependencies there. Instead the formula downloads the pre-built **no-JRE jar bundle** `amy--jvm.tar.gz` (published by `create-release.yml`) and `depends_on "openjdk"`. The reference formula lives at -[`packaging/homebrew/amy.rb`](packaging/homebrew/amy.rb). +[`cli/packaging/homebrew/amy.rb`](cli/packaging/homebrew/amy.rb). To submit: @@ -415,7 +415,7 @@ curl -fsSL -o amy-jvm.tar.gz \ https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz shasum -a 256 amy-jvm.tar.gz -# 2. Fill the url + sha256 into packaging/homebrew/amy.rb, then open the PR +# 2. Fill the url + sha256 into cli/packaging/homebrew/amy.rb, then open the PR brew create --set-name amy --tap homebrew/core \ https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz # (paste the reference formula body, run `brew audit --new amy`, diff --git a/packaging/homebrew/amy.rb b/cli/packaging/homebrew/amy.rb similarity index 100% rename from packaging/homebrew/amy.rb rename to cli/packaging/homebrew/amy.rb