diff --git a/.buildkite/commands/release-desktop.sh b/.buildkite/commands/release-desktop.sh new file mode 100755 index 00000000..28ca02db --- /dev/null +++ b/.buildkite/commands/release-desktop.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Build, sign, and notarize the Cortext desktop DMG. + +# Release tag builds publish to the GitHub Release. +if [[ "${BUILDKITE_TAG:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + version="$BUILDKITE_TAG" + publish=true +else + base_version="$(python3 -c 'import json; print(json.load(open("apps/desktop/package.json"))["version"])')" + version="${base_version}-${BUILDKITE_COMMIT:0:7}" + publish=false +fi + +echo "--- :package: install JS + PHP dependencies" +corepack enable +corepack prepare "pnpm@$(node -p "require('./package.json').packageManager.split('@')[1]")" --activate +# The xcode-* agent image ships no composer; brew pulls php in as a dependency. +command -v composer >/dev/null || brew install composer +composer install --no-dev --optimize-autoloader --no-interaction +pnpm install --frozen-lockfile +npm --prefix apps/desktop ci + +# Electron 42 skips the binary download during `npm ci`; fetch it so +# electron-builder packages a complete app. +( cd apps/desktop && npx install-electron ) + +echo "--- :hammer_and_wrench: install static-php-cli build toolchain" +# static-php-cli source-builds any library without a pre-built binary (e.g. +# libxml2), which the minimal agent image can't do unaided. +brew install cmake autoconf automake libtool bison re2c +export PATH="$(brew --prefix bison)/bin:$PATH" + +echo "--- :php: build bundled arm64 PHP runtime" +npm --prefix apps/desktop run runtime:php + +echo "--- :card_index_dividers: build distribution snapshot" +CORTEXT_DESKTOP_DISTRIBUTION=1 npm --prefix apps/desktop run snapshot + +echo "--- :key: install Developer ID cert into the agent keychain" +( cd apps/desktop && install_gems && bundle exec fastlane set_up_signing ) + +echo "--- :apple: build, sign, notarize DMG" +# electron-builder signs from the match-installed keychain cert (mac.identity) +# and notarizes via its built-in @electron/notarize, driven by APPLE_API_*. +# APPLE_API_KEY must be a path to the .p8, so materialize the key the agent +# carries as APP_STORE_CONNECT_API_KEY_KEY into a temp file. +apple_api_key_path="$(mktemp -t cortext_asc).p8" +trap 'rm -f "$apple_api_key_path"' EXIT +# The secret stores the .p8 with newlines as literal \n; %b turns them back into +# real newlines so the file is a valid PEM (a no-op if they are already real). +printf '%b' "$APP_STORE_CONNECT_API_KEY_KEY" > "$apple_api_key_path" +export APPLE_API_KEY="$apple_api_key_path" +export APPLE_API_KEY_ID="$APP_STORE_CONNECT_API_KEY_KEY_ID" +export APPLE_API_ISSUER="$APP_STORE_CONNECT_API_KEY_ISSUER_ID" + +npm --prefix apps/desktop run dist -- -c.extraMetadata.version="$version" + +echo "--- :white_check_mark: verify signature + notarization" +dmg=(apps/desktop/dist/*.dmg) +[[ ${#dmg[@]} -eq 1 ]] || { echo "Expected exactly one DMG, found ${#dmg[@]}"; exit 1; } +dmg="${dmg[0]}" +# electron-builder signs, notarizes and staples the .app, then wraps it in an +# unsigned .dmg — so the notarized artifact to verify is the app, not the dmg. +app="apps/desktop/dist/mac-arm64/Cortext.app" +codesign --verify --strict --deep --verbose=2 "$app" +spctl --assess --type exec --verbose=2 "$app" +xcrun stapler validate "$app" + +if ! "$publish"; then + echo "--- :information_source: no release tag; signed DMG stashed as a Buildkite artifact" + exit 0 +fi + +echo "--- :rocket: attach DMG to draft GitHub Release" +if ! gh release view "$version" --repo Automattic/cortext >/dev/null 2>&1; then + gh release create "$version" \ + --repo Automattic/cortext \ + --draft \ + --title "Cortext $version" \ + --notes "Cortext $version" +fi +gh release upload "$version" "$dmg" --repo Automattic/cortext --clobber diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml new file mode 100644 index 00000000..3a44735b --- /dev/null +++ b/.buildkite/pipeline.yml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json +--- + +agents: + queue: mac + +env: + IMAGE_ID: $IMAGE_ID + +steps: + - label: ":apple: Build, sign, notarize desktop DMG" + key: release-desktop + command: .buildkite/commands/release-desktop.sh + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + artifact_paths: + - "apps/desktop/dist/*.dmg" + - "apps/desktop/dist/*.dmg.blockmap" + notify: + - github_commit_status: + context: Build, Sign & Notarize Desktop diff --git a/.buildkite/shared-pipeline-vars b/.buildkite/shared-pipeline-vars new file mode 100755 index 00000000..e4d87ca4 --- /dev/null +++ b/.buildkite/shared-pipeline-vars @@ -0,0 +1,11 @@ +#!/bin/sh + +# Sourced by the pipeline-upload step so these values land in the rendered pipeline.yml. + +CI_TOOLKIT_PLUGIN_VERSION='6.0.1' +NVM_PLUGIN_VERSION='0.6.0' +XCODE_VERSION=$(sed -E 's/^~> ?//' .xcode-version) + +export IMAGE_ID="xcode-$XCODE_VERSION" +export CI_TOOLKIT_PLUGIN="automattic/a8c-ci-toolkit#$CI_TOOLKIT_PLUGIN_VERSION" +export NVM_PLUGIN="automattic/nvm#$NVM_PLUGIN_VERSION" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml deleted file mode 100644 index 5ac941d6..00000000 --- a/.github/workflows/release-desktop.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Release desktop DMG - -on: - workflow_call: - inputs: - version: - required: true - type: string - prerelease: - required: false - default: true - type: boolean - dry_run: - required: false - default: true - type: boolean - ref: - required: false - default: '' - type: string - workflow_dispatch: - inputs: - version: - description: 'Version' - required: true - default: '0.1.0' - prerelease: - description: 'Prerelease' - required: true - default: true - type: boolean - dry_run: - description: 'Dry run (uploads files only; no release)' - required: true - default: true - type: boolean - ref: - description: 'Git ref to build' - required: false - default: '' - -permissions: - contents: write - -concurrency: - group: cortext-release-desktop-${{ inputs.version }} - cancel-in-progress: true - -jobs: - desktop: - name: Build desktop DMG (macOS arm64) - runs-on: macos-14 - steps: - - uses: actions/checkout@v6.0.3 - with: - ref: ${{ inputs.ref || github.sha }} - - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - with: - version: 11.0.8 - standalone: true - - - uses: actions/setup-node@v6 - with: - node-version: '24.15' - cache: 'pnpm' - - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 - with: - php-version: '8.1' - tools: composer:v2 - coverage: none - - - uses: actions/cache@v5 - with: - path: ~/.composer/cache - key: composer-${{ hashFiles('composer.lock') }} - - - name: Update release metadata - env: - VERSION: ${{ inputs.version }} - run: node scripts/bump-release-version.mjs "$VERSION" - - - name: Install PHP dependencies - run: composer install --no-dev --optimize-autoloader --no-interaction - - - name: Install JS dependencies - run: pnpm install --frozen-lockfile - - - name: Install desktop dependencies - working-directory: apps/desktop - run: npm ci - - # Electron 42 skips the binary download during npm ci. Fetch it here - # so electron-builder packages a complete app. - - name: Install Electron binary - working-directory: apps/desktop - run: npx install-electron - - # static-php-cli compiles natively; a macos-14 runner yields arm64. - # The build is the slow part, so cache the result across runs. - - name: Cache bundled PHP runtime - uses: actions/cache@v5 - with: - path: | - apps/desktop/.runtime-cache - apps/desktop/runtime/bin - key: desktop-runtime-php-${{ hashFiles('apps/desktop/scripts/install-runtime.mjs') }} - - - name: Build bundled arm64 PHP - working-directory: apps/desktop - env: - GITHUB_TOKEN: ${{ github.token }} - run: npm run runtime:php - - - name: Cache snapshot downloads (WP core + wp-cli + SQLite plugin) - uses: actions/cache@v5 - with: - path: apps/desktop/.snapshot-cache - key: desktop-snapshot-cache-${{ hashFiles('apps/desktop/scripts/build-snapshot.mjs') }} - - - name: Build distribution snapshot - working-directory: apps/desktop - env: - CORTEXT_DESKTOP_DISTRIBUTION: '1' - run: npm run snapshot - - - name: Build unsigned DMG - working-directory: apps/desktop - env: - VERSION: ${{ inputs.version }} - run: npm run dist -- -c.extraMetadata.version="$VERSION" - - - name: Upload dry-run DMG - if: ${{ inputs.dry_run }} - uses: actions/upload-artifact@v7 - with: - name: cortext-desktop-dmg-${{ inputs.version }}-dry-run - path: apps/desktop/dist/*.dmg - if-no-files-found: error - - # Attach the DMG to the Release for this tag. Only files are set, so - # the plugin workflow stays the owner of the Release title and notes. - - name: Attach DMG to draft GitHub Release - if: ${{ ! inputs.dry_run }} - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 - with: - tag_name: ${{ inputs.version }} - draft: true - prerelease: ${{ inputs.prerelease }} - files: apps/desktop/dist/*.dmg diff --git a/.github/workflows/release-plugin.yml b/.github/workflows/release-plugin.yml index 079d2b5d..60907a8b 100644 --- a/.github/workflows/release-plugin.yml +++ b/.github/workflows/release-plugin.yml @@ -127,9 +127,9 @@ jobs: run: | cat >> release-notes.md <<'EOF' - ## Opening the macOS app + ## Installing the macOS app - This DMG is unsigned, so macOS may show a "Cortext is damaged" warning the first time you open it. Move Cortext to Applications. If that warning appears, click Cancel, open Terminal, and run `xattr -dr com.apple.quarantine /Applications/Cortext.app && open /Applications/Cortext.app`. + The macOS DMG is signed and notarized. Move Cortext to Applications before opening it. EOF - name: Build plugin ZIP diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9faa1b16..6fabb393 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,9 +26,8 @@ concurrency: group: cortext-release-${{ inputs.milestone }} cancel-in-progress: true -# Orchestrator: builds the plugin ZIP and the desktop DMG and lets each attach -# to the same tagged Release. Either child can also be run on its own from the -# Actions tab; the first run creates the draft Release, later runs attach to it. +# GitHub Actions prepares the plugin ZIP and draft Release. Buildkite owns the +# signed desktop DMG and attaches it from the release tag build. jobs: bump-version: name: Bump release version @@ -134,19 +133,9 @@ jobs: ref: ${{ needs.bump-version.outputs.ref }} secrets: inherit - desktop: - needs: bump-version - uses: ./.github/workflows/release-desktop.yml - with: - version: ${{ needs.bump-version.outputs.version }} - prerelease: ${{ inputs.prerelease }} - dry_run: ${{ inputs.dry_run }} - ref: ${{ needs.bump-version.outputs.ref }} - secrets: inherit - finalize-milestone: name: Finalize release milestone - needs: [bump-version, plugin, desktop] + needs: [bump-version, plugin] if: ${{ ! inputs.dry_run }} runs-on: ubuntu-latest steps: diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a2e33f6e --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.15 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..a0891f56 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.4 diff --git a/.xcode-version b/.xcode-version new file mode 100644 index 00000000..59c8fb19 --- /dev/null +++ b/.xcode-version @@ -0,0 +1 @@ +26.3 diff --git a/apps/desktop/.bundle/config b/apps/desktop/.bundle/config new file mode 100644 index 00000000..54298662 --- /dev/null +++ b/apps/desktop/.bundle/config @@ -0,0 +1,4 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_SPECIFIC_PLATFORM: "false" +BUNDLE_FORCE_RUBY_PLATFORM: "true" diff --git a/apps/desktop/Gemfile b/apps/desktop/Gemfile new file mode 100644 index 00000000..29ed5e0e --- /dev/null +++ b/apps/desktop/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'fastlane', '~> 2.236' +gem 'fastlane-plugin-wpmreleasetoolkit', '~> 14.6' diff --git a/apps/desktop/Gemfile.lock b/apps/desktop/Gemfile.lock new file mode 100644 index 00000000..99fbe5da --- /dev/null +++ b/apps/desktop/Gemfile.lock @@ -0,0 +1,303 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1259.0) + aws-sdk-core (3.251.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.225.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + buildkit (1.6.1) + sawyer (>= 0.6) + chroma (0.2.0) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.5) + declarative (0.0.20) + diffy (3.4.4) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + erubi (1.13.1) + excon (0.112.0) + faraday (1.10.5) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.236.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) + naturally (~> 2.2) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-wpmreleasetoolkit (14.6.0) + buildkit (~> 1.5) + chroma (= 0.2.0) + diffy (~> 3.3) + dotenv (~> 2.8) + fastlane (~> 2.231) + gettext (~> 3.5) + git (~> 1.3) + google-cloud-storage (~> 1.31) + java-properties (~> 0.3.0) + nokogiri (~> 1.19, >= 1.19.3) + octokit (~> 6.1) + parallel (~> 1.14) + plist (~> 3.1) + progress_bar (~> 1.3) + rake (>= 12.3, < 14.0) + rake-compiler (~> 1.0) + xcodeproj (~> 1.22) + fastlane-sirp (1.1.0) + fiddle (1.1.8) + forwardable (1.4.0) + gettext (3.5.2) + erubi + locale (>= 2.0.5) + prime + racc + text (>= 1.3.0) + gh_inspector (1.1.3) + git (1.19.1) + addressable (~> 2.8) + rchardet (~> 1.8) + google-apis-androidpublisher_v3 (0.102.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.63.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.60.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.2.0) + googleauth (1.17.0) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + os (>= 0.9, < 2.0) + pstore (~> 0.1) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + java-properties (0.3.0) + jmespath (1.6.2) + json (2.19.8) + jwt (3.2.0) + base64 + locale (2.1.5) + fiddle + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + mini_portile2 (2.8.9) + multi_json (1.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + nokogiri (1.19.3) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + octokit (6.1.1) + faraday (>= 1, < 3) + sawyer (~> 0.9) + options (2.3.2) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + parallel (1.28.0) + plist (3.7.2) + prime (0.1.4) + forwardable + singleton + progress_bar (1.3.4) + highline (>= 1.6) + options (~> 2.3.0) + pstore (0.2.1) + public_suffix (7.0.5) + racc (1.8.1) + rake (13.4.2) + rake-compiler (1.3.1) + rake + rchardet (1.10.2) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + sawyer (0.9.3) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) + security (0.1.5) + signet (0.22.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + simctl (1.6.10) + CFPropertyList + naturally + singleton (0.3.0) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + text (1.3.1) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + ruby + +DEPENDENCIES + fastlane (~> 2.236) + fastlane-plugin-wpmreleasetoolkit (~> 14.6) + +BUNDLED WITH + 2.5.11 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d330e13d..a951895e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -127,15 +127,17 @@ through wp-admin. The snapshot disables core, plugin, and theme updates, and each launch refreshes the update-lock mu-plugin in the extracted site. WordPress changes come through new Cortext desktop releases. -For now the build is arm64 only and unsigned. +Release builds are arm64-only, signed, and notarized on Buildkite. Local builds +remain unsigned unless you provide a signing environment. ## Releasing `.github/workflows/release.yml` is the entry point; run it from the Actions tab. -It calls two reusable workflows: `release-plugin.yml` builds the plugin ZIP and -owns the GitHub Release notes, and `release-desktop.yml` builds the arm64 PHP, -builds a distribution snapshot, runs electron-builder, and uploads the DMG. Both -write to the same Release by tag, and you can also run either one on its own. +It builds the plugin ZIP, validates the milestone, writes release notes, and +creates or updates the draft GitHub Release. Buildkite owns the macOS desktop +DMG: the release tag build builds the arm64 PHP runtime, builds the distribution +snapshot, runs electron-builder, signs and notarizes the app, and uploads the +DMG to the same Release. ## Performance diff --git a/apps/desktop/fastlane/.gitignore b/apps/desktop/fastlane/.gitignore new file mode 100644 index 00000000..56e5e3de --- /dev/null +++ b/apps/desktop/fastlane/.gitignore @@ -0,0 +1,2 @@ +README.md +report.xml diff --git a/apps/desktop/fastlane/Fastfile b/apps/desktop/fastlane/Fastfile new file mode 100644 index 00000000..1e842ce1 --- /dev/null +++ b/apps/desktop/fastlane/Fastfile @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'json' + +UI.user_error!('Please run fastlane via `bundle exec`') unless FastlaneCore::Helper.bundler? + +# The team id is the trailing `(XXXXXXXXXX)` of electron-builder's signing identity. +APPLE_TEAM_ID = JSON.parse(File.read(File.expand_path('../package.json', __dir__))).dig('build', 'mac', 'identity')[/\(([A-Z0-9]+)\)\z/, 1] +UI.user_error!("Couldn't parse Apple Team ID from build.mac.identity in apps/desktop/package.json") unless APPLE_TEAM_ID +CODE_SIGNING_STORAGE_OPTIONS = { + storage_mode: 's3', + s3_bucket: 'a8c-fastlane-match', + s3_region: 'us-east-2' +}.freeze + +CODE_SIGNING_ENV_VARS = %w[ + MATCH_S3_ACCESS_KEY + MATCH_S3_SECRET_ACCESS_KEY + MATCH_PASSWORD +].freeze + +ASC_API_KEY_ENV_VARS = %w[ + APP_STORE_CONNECT_API_KEY_KEY_ID + APP_STORE_CONNECT_API_KEY_ISSUER_ID + APP_STORE_CONNECT_API_KEY_KEY +].freeze + +require 'fastlane/plugin/wpmreleasetoolkit' + +EnvManager = Fastlane::Wpmreleasetoolkit::EnvManager + +before_all do + # Sets up a temporary keychain so match works in CI. No-op locally. + setup_ci + + # `set_up` is needed even with no `.env` file so EnvManager has a configured + # instance; on CI the vars come from the Buildkite agent. + EnvManager.set_up(env_file_name: 'cortext-desktop.env') +end + +desc 'Fetch the Developer ID Application certificate into the keychain' +lane :set_up_signing do |readonly: true| + CODE_SIGNING_ENV_VARS.each { |k| EnvManager.get_required_env!(k) } + ASC_API_KEY_ENV_VARS.each { |k| EnvManager.get_required_env!(k) } unless readonly + + sync_code_signing( + type: 'developer_id', + platform: 'macos', + team_id: APPLE_TEAM_ID, + app_identifier: [], + api_key: readonly ? nil : app_store_connect_api_key, + readonly: readonly, + **CODE_SIGNING_STORAGE_OPTIONS + ) +end diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8390ddd1..d0333fd8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -54,9 +54,16 @@ "arch": "arm64" } ], - "identity": null, + "identity": "Automattic, Inc. (PZYM8XX95Q)", "category": "public.app-category.productivity", - "minimumSystemVersion": "12.0.0" + "minimumSystemVersion": "12.0.0", + "hardenedRuntime": true, + "entitlements": "signing/entitlements.mac.plist", + "entitlementsInherit": "signing/entitlements.mac.plist", + "binaries": [ + "Contents/Resources/runtime/bin/php" + ], + "notarize": true } } } diff --git a/apps/desktop/signing/entitlements.mac.plist b/apps/desktop/signing/entitlements.mac.plist new file mode 100644 index 00000000..b62aca4a --- /dev/null +++ b/apps/desktop/signing/entitlements.mac.plist @@ -0,0 +1,15 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + diff --git a/docs/desktop-decisions.md b/docs/desktop-decisions.md index 0348d37b..ff8cfd70 100644 --- a/docs/desktop-decisions.md +++ b/docs/desktop-decisions.md @@ -2,11 +2,19 @@ Running log for desktop-specific runtime and packaging decisions. Keep the detailed benchmark numbers in PR comments or artifacts unless they become stable product guidance. +## 2026-06-16 — Buildkite owns the signed macOS DMG + +**Decision.** GitHub Actions prepares the plugin ZIP, validates the release milestone, writes release notes, and creates or updates the draft GitHub Release. Buildkite is the only macOS DMG publisher: the release tag build builds the bundled PHP runtime and distribution snapshot, runs electron-builder, signs and notarizes the app, verifies the signed app, and uploads the DMG to the same draft Release. + +**Why.** Keeping the desktop artifact in one CI system avoids two automation paths racing to build or upload the same DMG. Buildkite also has the macOS signing and notarization environment needed for the release artifact. + +**Revisit when.** Desktop releases need additional architectures, auto-update artifacts, or a different release owner. + ## 2026-06-02 — Package the desktop app as an unsigned arm64 DMG **Decision.** `apps/desktop` packages to a macOS DMG with electron-builder. `npm --prefix apps/desktop run dist` runs the build against a `build` block in `package.json`: appId `com.automattic.cortext`, product name `Cortext`, a single arm64 `dmg` target, `identity: null` so it is unsigned, and `snapshot.zip` plus `runtime/bin/php` listed as `extraResources`. The output lands in `apps/desktop/dist`. `main.js` reads the bundled snapshot and PHP from `process.resourcesPath` when `app.isPackaged`, and DevTools stay closed in the packaged build. The distribution snapshot is built with `CORTEXT_DESKTOP_DISTRIBUTION=1`, which ships only the autologin mu-plugin and drops the timing and runtime-probe ones; the autologin mu-plugin is now inert unless `CORTEXT_DESKTOP` is defined. -**Release flow.** `release.yml` is a `workflow_dispatch` orchestrator. It takes an explicit release milestone, bumps metadata to that milestone version first, committing it on real releases and only applying it locally on dry runs. Then it calls two reusable workflows against the bumped commit: `release-plugin.yml` (plugin ZIP, milestone, notes) and `release-desktop.yml` (builds the arm64 PHP, builds the distribution snapshot, runs electron-builder, attaches the DMG). Both write to the same Release by tag, so the first run creates the draft and later runs add to it. Successful releases close the published milestone; non-patch releases also create the next non-patch milestone. Each child workflow can also run on its own from the Actions tab. The shipped app checks GitHub Releases on launch and links to the download when a newer version exists; installing updates in place is not done, since that needs a signed app and Squirrel.Mac. +**Release flow.** This original GitHub Actions desktop release flow was superseded by the 2026-06-16 Buildkite release flow. `release.yml` still handles the release milestone, version bump, plugin ZIP, notes, draft GitHub Release, and milestone finalization. Buildkite is now the only desktop DMG publisher: release tag builds sign, notarize, verify, and upload the DMG to the same Release. The shipped app checks GitHub Releases on launch and links to the download when a newer version exists; installing updates in place is not done, since that needs Squirrel.Mac. **Why unsigned and arm64 only.** This is an alpha for technical testers, so we skip the Apple Developer ID, signing, and notarization for now and live with the Open Anyway step on first launch. arm64 is the only target because static-php-cli does not cross-compile on macOS, so a second architecture means a second native runner. @@ -30,10 +38,10 @@ Running log for desktop-specific runtime and packaging decisions. Keep the detai **Follow-up order.** After merge, measure each idea against the merged baseline: -- OPcache `file_cache` first, because it is runtime-configurable and does not need a PHP rebuild. -- Request/query profiling for the Library workflow before adding object cache, so we know whether APCu is fixing real repeated reads or just adding cache coherency risk across CLI server workers. -- Preload or targeted `opcache_compile_file()` only after the hot PHP files are known from profiling. -- A JIT-enabled PHP rebuild last. If tested, verify `opcache_get_status()['jit']['buffer_used'] > 0` after warmup and measure memory at 4 and 8 workers. +- OPcache `file_cache` first, because it is runtime-configurable and does not need a PHP rebuild. +- Request/query profiling for the Library workflow before adding object cache, so we know whether APCu is fixing real repeated reads or just adding cache coherency risk across CLI server workers. +- Preload or targeted `opcache_compile_file()` only after the hot PHP files are known from profiling. +- A JIT-enabled PHP rebuild last. If tested, verify `opcache_get_status()['jit']['buffer_used'] > 0` after warmup and measure memory at 4 and 8 workers. **Not in this branch.** Do not make FrankenPHP the default, do not switch v1 desktop back to Playground/WASM, and do not bundle PHP-FPM/Caddy unless a follow-up benchmark shows a real Library workflow win that justifies the packaging cost. diff --git a/docs/release.md b/docs/release.md index 9d07ac45..0e928299 100644 --- a/docs/release.md +++ b/docs/release.md @@ -115,27 +115,28 @@ Releases run from the Actions tab, through the "Prepare release" workflow (`.github/workflows/release.yml`). It takes a `milestone`, a `prerelease` flag, and a `dry_run` flag. The milestone is the release version, such as `0.1.1` or `0.2.0`, and it must already exist and be open. Run it with `dry_run` on first: -that applies the version bump inside the checkout, builds everything, and -uploads the artifacts without creating a commit, tag, or Release, and without -closing the milestone or creating a next milestone. Turn `dry_run` off to commit -the version bump, push it to the selected branch, and publish a draft Release. +that applies the version bump inside the checkout, builds the plugin ZIP, and +uploads the release notes without creating a commit, tag, or Release, and +without closing the milestone or creating a next milestone. Turn `dry_run` off +to commit the version bump, push it to the selected branch, and publish a draft +Release. "Prepare release" first updates the plugin header, `CORTEXT_VERSION`, `readme.txt` stable tag, root package version, and desktop package versions to the requested milestone version. On a real release, it commits those changes as -`chore: bump release to ` before building. It then calls two reusable -workflows against the bumped commit: +`chore: bump release to ` before building. It then calls +`release-plugin.yml` against the bumped commit to build the plugin ZIP, validate +the milestone, write release notes, and create or update the draft GitHub +Release. -- `release-plugin.yml` resolves the milestone, builds the changelog and the - plugin ZIP, and creates the draft Release. -- `release-desktop.yml` builds the bundled PHP and the snapshot, runs - electron-builder, and attaches the macOS DMG. +Buildkite owns the macOS desktop DMG. The release tag build signs, notarizes, +and staples the app, then uploads the DMG to the same draft Release. -Both write to the same Release by tag. Whichever runs first creates the draft, -and the other adds its artifact. The plugin run owns the title and notes; the -desktop run only uploads the DMG. You can also run either workflow on its own -from the Actions tab, which is handy for rebuilding just the DMG against a draft -that already exists. +If the DMG needs to be rebuilt, rerun the Buildkite release tag build rather +than starting a GitHub Actions workflow. + +Both systems write to the same Release by tag, but only Buildkite uploads the +desktop artifact. The GitHub Actions run owns the title, notes, and plugin ZIP. ## Deploying to WordPress.org @@ -179,10 +180,8 @@ next milestone. ## Desktop app The desktop release builds a macOS DMG with electron-builder and attaches it to -the same Release as the plugin ZIP. The build is arm64-only and unsigned for -now, so macOS may show a "Cortext is damaged" warning the first time it opens. -Release notes should tell people to move Cortext to Applications. If the warning -appears, they should click Cancel, open Terminal, and run -`xattr -dr com.apple.quarantine /Applications/Cortext.app && open /Applications/Cortext.app`. +the same Release as the plugin ZIP. The release build is arm64-only, signed, and +notarized by Buildkite. Release notes should tell people to move Cortext to +Applications. The installed app checks GitHub Releases on launch and links to the download when a newer version exists, but it does not update itself.