diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ccd8f92..11fa5d19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,10 +76,60 @@ jobs: - name: Build app run: pnpm build + - name: Verify mac signing secrets + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + env: + MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + MAC_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 }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + missing=() + + if [[ -z "${MAC_CSC_LINK}" ]]; then + missing+=("MAC_CSC_LINK") + fi + if [[ -z "${MAC_CSC_KEY_PASSWORD}" ]]; then + missing+=("MAC_CSC_KEY_PASSWORD") + fi + + has_apple_id_credentials=false + if [[ -n "${APPLE_ID}" && -n "${APPLE_APP_SPECIFIC_PASSWORD}" && -n "${APPLE_TEAM_ID}" ]]; then + has_apple_id_credentials=true + fi + + has_api_key_credentials=false + if [[ -n "${APPLE_API_KEY}" && -n "${APPLE_API_KEY_ID}" && -n "${APPLE_API_ISSUER}" ]]; then + has_api_key_credentials=true + fi + + if [[ "${has_apple_id_credentials}" != "true" && "${has_api_key_credentials}" != "true" ]]; then + missing+=("APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID or APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER") + fi + + if [[ "${#missing[@]}" -gt 0 ]]; then + printf 'Missing macOS release signing/notarization secret(s):\n' + printf ' - %s\n' "${missing[@]}" + exit 1 + fi + - name: Package (mac arm64 + x64) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_IDENTITY_AUTO_DISCOVERY: "false" + 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 }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + DEBUG: electron-notarize* run: > pnpm exec electron-builder --mac diff --git a/package.json b/package.json index ebb0cf18..20c98336 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@electron/notarize": "^2.5.0", "@electron/rebuild": "^4.0.3", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dadb5fef..eeb40c82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: specifier: ^5.0.12 version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: + '@electron/notarize': + specifier: ^2.5.0 + version: 2.5.0 '@electron/rebuild': specifier: ^4.0.3 version: 4.0.3 diff --git a/scripts/notarize.js b/scripts/notarize.js index 989f9e87..c7ef64df 100644 --- a/scripts/notarize.js +++ b/scripts/notarize.js @@ -1,26 +1,111 @@ -// macOS notarization hook for electron-builder -// Requires: pnpm add -D @electron/notarize -// Requires: APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID env vars +// macOS notarization hook for electron-builder. +// Release builds must provide: +// - Developer ID signing credentials: CSC_LINK + CSC_KEY_PASSWORD (or CSC_NAME from a prepared keychain) +// - Notarization credentials: either Apple ID credentials or App Store Connect API key credentials + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +function isReleaseBuild() { + return process.env.HARNSS_REQUIRE_NOTARIZATION === "true" || process.env.GITHUB_REF?.startsWith("refs/tags/v"); +} + +function hasSigningCredentials() { + return Boolean(process.env.CSC_LINK || process.env.CSC_NAME); +} + +function hasEveryEnv(names) { + return names.every((name) => Boolean(process.env[name])); +} + +function hasSomeEnv(names) { + return names.some((name) => Boolean(process.env[name])); +} + +function createApiKeyFile(apiKey, apiKeyId) { + if (fs.existsSync(apiKey)) return { apiKeyPath: apiKey, cleanup: undefined }; + + const keyPath = path.join(os.tmpdir(), `AuthKey_${apiKeyId}.p8`); + const normalizedApiKey = apiKey.includes("BEGIN PRIVATE KEY") + ? apiKey.replace(/\\n/g, "\n") + : Buffer.from(apiKey, "base64").toString("utf8"); + + if (!normalizedApiKey.includes("BEGIN PRIVATE KEY")) { + throw new Error("APPLE_API_KEY must be a .p8 file path, raw .p8 contents, or base64-encoded .p8 contents"); + } + + fs.writeFileSync(keyPath, normalizedApiKey, { mode: 0o600 }); + return { apiKeyPath: keyPath, cleanup: () => fs.rmSync(keyPath, { force: true }) }; +} + +function resolveNotarizationOptions() { + const apiKeyEnv = ["APPLE_API_KEY", "APPLE_API_KEY_ID", "APPLE_API_ISSUER"]; + if (hasEveryEnv(apiKeyEnv)) { + const { apiKeyPath, cleanup } = createApiKeyFile(process.env.APPLE_API_KEY, process.env.APPLE_API_KEY_ID); + return { + options: { + appleApiKey: apiKeyPath, + appleApiKeyId: process.env.APPLE_API_KEY_ID, + appleApiIssuer: process.env.APPLE_API_ISSUER, + }, + cleanup, + }; + } + + const appleIdEnv = ["APPLE_ID", "APPLE_APP_SPECIFIC_PASSWORD", "APPLE_TEAM_ID"]; + if (hasEveryEnv(appleIdEnv)) { + return { + options: { + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, + teamId: process.env.APPLE_TEAM_ID, + }, + cleanup: undefined, + }; + } + + if (hasSomeEnv(apiKeyEnv)) { + throw new Error(`Incomplete App Store Connect API key credentials. Required: ${apiKeyEnv.join(", ")}`); + } + if (process.env.APPLE_ID || process.env.APPLE_APP_SPECIFIC_PASSWORD) { + throw new Error(`Incomplete Apple ID notarization credentials. Required: ${appleIdEnv.join(", ")}`); + } + + return { options: undefined, cleanup: undefined }; +} exports.default = async function notarizing(context) { const { electronPlatformName, appOutDir } = context; if (electronPlatformName !== "darwin") return; - if (!process.env.APPLE_ID || !process.env.APPLE_APP_SPECIFIC_PASSWORD) { - console.log("Skipping notarization: no Apple credentials set"); - return; - } - - const { notarize } = require("@electron/notarize"); const appName = context.packager.appInfo.productFilename; + const appPath = path.join(appOutDir, `${appName}.app`); + const releaseBuild = isReleaseBuild(); + const { options, cleanup } = resolveNotarizationOptions(); + + try { + if (!options) { + if (releaseBuild) { + throw new Error("Refusing to publish an unnotarized macOS release. Configure Apple notarization credentials."); + } + console.log("Skipping notarization: no Apple notarization credentials set"); + return; + } - console.log(`Notarizing ${appName}...`); - await notarize({ - appBundleId: "com.harnss.app", - appPath: `${appOutDir}/${appName}.app`, - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, - teamId: process.env.APPLE_TEAM_ID, - }); - console.log("Notarization complete"); + if (releaseBuild && !hasSigningCredentials()) { + throw new Error("Refusing to publish an unsigned macOS release. Configure Developer ID signing credentials."); + } + + const { notarize } = require("@electron/notarize"); + + console.log(`Notarizing ${appName}...`); + await notarize({ + appPath, + ...options, + }); + console.log("Notarization complete"); + } finally { + cleanup?.(); + } };