From 3d236df64a249ad9248a6aa6e59a9f43054e83f7 Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Mon, 28 Sep 2026 16:59:16 +0200 Subject: [PATCH] feat(release): build and publish macOS from the Mac, not CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Developer ID certificate and the notarytool keychain profile live only in the release Mac's keychain, so a CI runner can only produce an unsigned mac build — and the app now updates itself through Squirrel.Mac, which refuses an update not signed by the same Developer ID. macOS is released locally: npm run release patch # tag → release.yml + Windows/Linux in CI (unchanged) npm run release:mac # this Mac: build, verify, upload, bump the cask scripts/release-mac.js: - preflight: HEAD is the tag, tree clean, origin's tag = HEAD; the notarytool profile works; the GitHub Release exists and is not a draft. - electron-builder --mac --publish NEVER: it skips notarization silently when the profile is missing, so --publish always would ship that build. - verify before upload: codesign; spctl (exit 0 + "Notarized Developer ID") on the app and on the app inside the mounted dmg, both at the tag's version; stapler; latest-mac.yml names this zip and carries the real sha512 of the zip and dmg. - gh release upload; existing mac assets only with --force; a part-way failure says what is and is not on the release. - transitional cask bump with the dmg's sha256 only once it equals the digest GitHub serves; --cask-only redoes that step. release-desktop.yml builds Windows/Linux only; the MAC_*/APPLE_* secrets and bump-cask are gone (HOMEBREW_TAP_TOKEN is now unused). Reviewed by codex; its findings are fixed and pinned. Verified on the notarized 7.17.0 build: the verification step passes, --cask-only refuses a dmg that is not the release's. Tests: test/release-mac.test.js (38 checks, each guard mutation-checked); test/mac-signing.test.js now pins that CI does not build macOS. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release-desktop.yml | 98 +------- CLAUDE.md | 17 +- docs/electron-desktop/MAC-SIGNING.md | 80 +++---- homebrew-tap/README.md | 8 +- package.json | 3 +- scripts/release-mac.js | 310 ++++++++++++++++++++++++++ scripts/release.js | 4 + test/mac-signing.test.js | 75 +++---- test/release-mac.test.js | 177 +++++++++++++++ 9 files changed, 582 insertions(+), 190 deletions(-) create mode 100644 scripts/release-mac.js create mode 100644 test/release-mac.test.js diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index c929f77f..f961f698 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -1,19 +1,17 @@ name: Release Desktop # Runs on the same version tags as the web release workflow. release.yml creates -# the GitHub Release + changelog; this workflow builds the desktop apps for all -# three OSes and uploads them to that release, then bumps the Homebrew Cask. +# the GitHub Release + changelog; this workflow builds the Windows and Linux apps +# and uploads them to that release. Both stay unsigned +# (CSC_IDENTITY_AUTO_DISCOVERY=false). # -# The cask bump depends on the macOS build ONLY (build-mac) — never on the -# Windows/Linux legs. A slow Windows build no longer delays macOS auto-updates, -# and a failing Windows/Linux build can no longer block the cask bump entirely -# (previously `bump-cask: needs: build` waited for the whole matrix and was -# skipped if any leg failed, so macOS users were stranded on the old version). -# -# macOS: Apple Silicon only, signed with the Developer ID certificate and notarized -# (docs/electron-desktop/MAC-SIGNING.md). Without the MAC_CSC_* / APPLE_* secrets -# the mac leg FAILS — the app self-updates via Squirrel.Mac, which refuses unsigned -# updates. Windows/Linux stay unsigned (CSC_IDENTITY_AUTO_DISCOVERY=false). +# macOS is NOT built here. It is signed with the Developer ID certificate and +# notarized with a notarytool keychain profile, and both live only in the release +# Mac's keychain — `npm run release:mac` builds, verifies and uploads it from there +# (scripts/release-mac.js, docs/electron-desktop/MAC-SIGNING.md). A runner has +# neither, and an unsigned mac build would break every installed copy: the app +# updates itself through Squirrel.Mac, which refuses an update not signed by the +# same Developer ID. on: push: tags: ['v*.*.*'] @@ -23,45 +21,7 @@ permissions: contents: write jobs: - build-mac: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - run: npm ci - - name: Build & publish desktop app (macOS) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # MAC_-prefixed secrets: electron-builder reads CSC_LINK on Windows too, so the - # name has to say which platform this certificate belongs to. - CSC_LINK: ${{ secrets.MAC_CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - set -euo pipefail - # No unsigned fallback. The app updates itself through Squirrel.Mac, which - # installs only an update signed by the same Developer ID — an unsigned - # release would be refused by every installed copy, and its dmg blocked by - # Gatekeeper for every new one. - if [ -z "${CSC_LINK:-}" ]; then - echo "::error::MAC_CSC_LINK is not set — refusing to publish an unsigned macOS build. See docs/electron-desktop/MAC-SIGNING.md." - exit 1 - fi - if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then - # electron-builder would sign, log "skipped macOS notarization" and publish. - # Gatekeeper refuses a signed-but-unnotarized download exactly like an - # unsigned one, so that build is a failure that looks like a success. - echo "::error::MAC_CSC_LINK is set but APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID are not — refusing to publish a signed but un-notarized dmg." - exit 1 - fi - npx electron-builder --mac --arm64 --publish always - - build-others: + build: strategy: fail-fast: false matrix: @@ -83,39 +43,3 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_IDENTITY_AUTO_DISCOVERY: 'false' run: npx electron-builder ${{ matrix.args }} --publish always - - bump-cask: - needs: build-mac - runs-on: ubuntu-latest - steps: - - name: Update Homebrew tap cask (skips if HOMEBREW_TAP_TOKEN not set) - env: - TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - run: | - set -euo pipefail - if [ -z "${TAP_TOKEN:-}" ]; then - echo "HOMEBREW_TAP_TOKEN not set — skipping cask bump. See homebrew-tap/README.md to enable the tap." - exit 0 - fi - VERSION="${GITHUB_REF_NAME#v}" - BASE="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}" - curl -fL "$BASE/claude-code-studio-${VERSION}-arm64.dmg" -o arm64.dmg - ARM_SHA="$(shasum -a 256 arm64.dmg | awk '{print $1}')" - git clone "https://x-access-token:${TAP_TOKEN}@github.com/${GITHUB_REPOSITORY_OWNER}/homebrew-claude-code-studio.git" tap - CASK="tap/Casks/claude-code-studio.rb" - sed -i -E "s/^ version \".*\"/ version \"${VERSION}\"/" "$CASK" - sed -i -E "s/^ sha256 \"[a-f0-9]{64}\"/ sha256 \"${ARM_SHA}\"/" "$CASK" - # sed exits 0 when its pattern matches nothing. A cask in any other shape - # (the old per-arch `sha256 arm:/intel:` stanza) would be pushed with the - # NEW version and the OLD checksum — every install then fails brew's sha - # check. Refuse instead of publishing that. - grep -q "^ version \"${VERSION}\"$" "$CASK" && grep -q "^ sha256 \"${ARM_SHA}\"$" "$CASK" || { - echo "::error::$CASK was not updated to ${VERSION} / ${ARM_SHA} — it must carry a single-arch \`sha256 \"…\"\` line." - exit 1 - } - cd tap - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Casks/claude-code-studio.rb - git commit -m "chore: claude-code-studio ${VERSION}" || echo "cask unchanged" - git push diff --git a/CLAUDE.md b/CLAUDE.md index 09081cb6..a188ef54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ docker compose up -d docker compose logs -f claude-chat ``` -No linting and no build step configured. `npm test` chains 85 test files under `test/`: 19 DOM-less render/UI-logic tests (`test/render/*.test.mjs`, run through `node --test`) plus 66 plain-`node` suites in `test/` covering the overload detector, env load order, multi-agent results, terminals, bots, telegram, updates, kanban scheduling, the Kanban card's run-settings badges (`kanban-run-badges.test.js` pins the card's model/effort/engine chain against the one `startTask` actually resolves — the two live in different files and the card silently lies when they drift), the board-only `create_task` status (`task-backlog.test.js`), the Kanban group form (`kanban-group-button.test.js` RUNS the real `buildChainForm` in a `vm` context so a stray free variable in its template throws there instead of silently killing the modal — see #115 — and parses every inline `