Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 103 additions & 18 deletions scripts/notarize.js
Original file line number Diff line number Diff line change
@@ -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(", ")}`);
}
Comment on lines +71 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat APPLE_TEAM_ID as part of the incomplete Apple ID check.

With only APPLE_TEAM_ID set, this falls through as “no credentials” instead of flagging a partial Apple ID configuration. That makes local misconfigurations easy to miss and downgrades tagged builds to the generic error path.

Proposed fix
-  if (process.env.APPLE_ID || process.env.APPLE_APP_SPECIFIC_PASSWORD) {
+  if (hasSomeEnv(appleIdEnv)) {
     throw new Error(`Incomplete Apple ID notarization credentials. Required: ${appleIdEnv.join(", ")}`);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/notarize.js` around lines 71 - 73, The current if-block uses the
wrong condition and omits APPLE_TEAM_ID: update the incomplete-credentials check
(the if near the throw that references appleIdEnv) to treat APPLE_TEAM_ID as
part of the required set and to throw only when there is a partial configuration
(i.e., at least one of appleIdEnv is set but not all). Concretely, include
"APPLE_TEAM_ID" in appleIdEnv if not already, compute missing =
appleIdEnv.filter(k => !process.env[k]) and if (missing.length > 0 &&
appleIdEnv.some(k => process.env[k])) throw an Error listing the missing keys
(use appleIdEnv.join or missing.join for the message) so partial configs are
flagged.


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?.();
}
};