Skip to content
Merged
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
63 changes: 53 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
name: release

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag_name:
Expand All @@ -22,6 +19,9 @@ env:
jobs:
release:
name: GoReleaser
if: >-
endsWith(github.workflow_ref, format('@refs/heads/{0}', github.event.repository.default_branch)) &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
Expand Down Expand Up @@ -71,17 +71,13 @@ jobs:

desktop:
name: ${{ matrix.name }}
if: >-
endsWith(github.workflow_ref, format('@refs/heads/{0}', github.event.repository.default_branch)) &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
strategy:
fail-fast: false
matrix:
include:
- name: macOS Desktop
os: macos-latest
platform: mac
builder_args: --mac dmg zip --x64 --arm64
files: |
apps/desktop/release/*.dmg
apps/desktop/release/*.zip
- name: Windows Desktop
os: windows-latest
platform: win
Expand Down Expand Up @@ -159,11 +155,58 @@ jobs:
if-no-files-found: error
retention-days: 14

verify-macos:
name: Verify signed macOS release assets
if: >-
endsWith(github.workflow_ref, format('@refs/heads/{0}', github.event.repository.default_branch)) &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: macos-latest
timeout-minutes: 20
permissions:
contents: write
steps:
- name: Check out trusted verifier
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: ${{ github.workflow_sha }}

- name: Download signed macOS draft assets
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
mkdir -p macos-release
gh release download "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--dir macos-release \
--pattern "ClickClack-${version}-mac-*.dmg" \
--pattern "ClickClack-${version}-mac-*.zip" \
--pattern "ClickClack-${version}-mac-SHA256SUMS.txt"

- name: Verify checksums and sealed apps
env:
RELEASE_TAG: ${{ inputs.tag_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
(
cd macos-release
shasum -a 256 --check "ClickClack-${version}-mac-SHA256SUMS.txt"
)
apps/desktop/scripts/verify-macos-release.sh "$RELEASE_TAG" macos-release

publish-desktop:
name: Publish desktop apps
if: >-
endsWith(github.workflow_ref, format('@refs/heads/{0}', github.event.repository.default_branch)) &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
needs:
- release
- desktop
- verify-macos
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## 0.2.1 - Unreleased

- Signed and notarized macOS desktop release bundles with the OpenClaw Foundation identity, hardened runtime, and strict sealed-artifact verification.

## 0.2.0 - 2026-07-17

### Highlights
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ productName: ClickClack
copyright: Copyright © OpenClaw contributors
asar: true
compression: normal
afterSign: scripts/after-sign.mjs
directories:
output: release
files:
Expand All @@ -23,6 +24,8 @@ mac:
icon: assets/icon.icns
hardenedRuntime: true
gatekeeperAssess: false
strictVerify: true
notarize: false
minimumSystemVersion: "12.0"
target:
- dmg
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dist": "pnpm build && electron-builder --publish never",
"dist:linux": "pnpm build && electron-builder --linux AppImage deb --x64 --publish never",
"dist:mac": "pnpm build && electron-builder --mac dmg zip --x64 --arm64 --publish never",
"dist:mac:release": "bash scripts/package-macos-release.sh",
"dist:win": "pnpm build && electron-builder --win nsis zip --x64 --publish never",
"pack": "pnpm build && electron-builder --dir --publish never",
"test": "node scripts/test.mjs",
Expand Down
120 changes: 120 additions & 0 deletions apps/desktop/scripts/after-sign.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";

const execFile = promisify(execFileCallback);

export const EXPECTED_IDENTITY = "Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)";
export const EXPECTED_IDENTITY_QUALIFIER = "OpenClaw Foundation (FWJYW4S8P8)";
export const NOTARY_RETRY_DELAY_MS = 10 * 60 * 1000;

function commandFailure(error) {
return [error?.message, error?.stdout, error?.stderr].filter(Boolean).join("\n");
}

export function isConnectTimeout(error) {
return /connectTimeout/i.test(commandFailure(error));
}

export async function submitNotarizationWithRetry(submit, delay) {
try {
return await submit();
} catch (error) {
if (!isConnectTimeout(error)) {
throw error;
}
console.warn("notarytool hit connectTimeout; waiting 10 minutes before one retry");
await delay(NOTARY_RETRY_DELAY_MS);
return submit();
}
}

export function assertAcceptedNotarization(rawResult) {
let result;
try {
result = JSON.parse(rawResult);
} catch {
throw new Error("notarytool did not return valid JSON");
}
if (result.status !== "Accepted") {
throw new Error(`notarytool returned ${result.status || "no status"}, expected Accepted`);
}
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(result.id || "")) {
throw new Error("notarytool returned an invalid submission id");
}
return result;
}

async function run(command, args, options = {}) {
return execFile(command, args, {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
...options,
});
}

async function verifyApp(script, appPath, requireNotarized = false) {
const args = requireNotarized ? ["--require-notarized", appPath] : [appPath];
await run("bash", [script, ...args]);
}

export async function notarizeApp(appPath, options = {}) {
const profile = options.profile || process.env.NOTARYTOOL_KEYCHAIN_PROFILE;
if (!profile) {
throw new Error("official macOS packaging requires NOTARYTOOL_KEYCHAIN_PROFILE");
}

const identity = options.identity || process.env.CSC_NAME;
if (identity !== EXPECTED_IDENTITY_QUALIFIER) {
throw new Error(`official macOS packaging requires ${EXPECTED_IDENTITY_QUALIFIER}`);
}

const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "verify-macos-app.sh");
const workDirectory = await mkdtemp(path.join(os.tmpdir(), "clickclack-notary."));
const submission = path.join(workDirectory, "ClickClack.zip");
const delay =
options.delay ||
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));

try {
await verifyApp(script, appPath);
await run("ditto", ["-c", "-k", "--sequesterRsrc", "--keepParent", appPath, submission]);

const submit = async () => {
const { stdout } = await run("xcrun", [
"notarytool",
"submit",
submission,
"--keychain-profile",
profile,
"--no-s3-acceleration",
"--wait",
"--output-format",
"json",
]);
return stdout;
};
const result = await submitNotarizationWithRetry(submit, delay);
assertAcceptedNotarization(result);

await run("xcrun", ["stapler", "staple", appPath]);
await verifyApp(script, appPath, true);
} finally {
await rm(workDirectory, { recursive: true, force: true });
}
}

export default async function afterSign(context) {
if (
context.electronPlatformName !== "darwin" ||
process.env.CLICKCLACK_OFFICIAL_MACOS_RELEASE !== "1"
) {
return;
}

const appName = `${context.packager.appInfo.productFilename}.app`;
await notarizeApp(path.join(context.appOutDir, appName));
}
86 changes: 86 additions & 0 deletions apps/desktop/scripts/macos-signing.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

import {
EXPECTED_IDENTITY,
EXPECTED_IDENTITY_QUALIFIER,
NOTARY_RETRY_DELAY_MS,
assertAcceptedNotarization,
submitNotarizationWithRetry,
} from "./after-sign.mjs";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repo = path.resolve(root, "../..");
const accepted = JSON.stringify({
id: "12345678-1234-1234-1234-123456789abc",
status: "Accepted",
});

test("notarization retries one connectTimeout after exactly ten minutes", async () => {
let attempts = 0;
const delays = [];
const result = await submitNotarizationWithRetry(
async () => {
attempts += 1;
if (attempts === 1) {
const error = new Error("notary transport failed");
error.stderr = "connectTimeout";
throw error;
}
return accepted;
},
async (milliseconds) => delays.push(milliseconds),
);
assert.equal(result, accepted);
assert.equal(attempts, 2);
assert.deepEqual(delays, [NOTARY_RETRY_DELAY_MS]);
});

test("notarization does not retry other failures", async () => {
let attempts = 0;
await assert.rejects(
submitNotarizationWithRetry(
async () => {
attempts += 1;
throw new Error("invalid credentials");
},
async () => assert.fail("unexpected delay"),
),
/invalid credentials/,
);
assert.equal(attempts, 1);
});

test("notarization accepts only Apple's accepted response shape", () => {
assert.equal(assertAcceptedNotarization(accepted).status, "Accepted");
assert.throws(() => assertAcceptedNotarization("{}"), /no status/);
assert.throws(
() => assertAcceptedNotarization('{"id":"not-a-uuid","status":"Accepted"}'),
/invalid submission id/,
);
});

test("macOS release policy is fail-closed and Foundation-scoped", async () => {
const [config, packageScript, verifier, workflow] = await Promise.all([
readFile(path.join(root, "electron-builder.yml"), "utf8"),
readFile(path.join(root, "scripts", "package-macos-release.sh"), "utf8"),
readFile(path.join(root, "scripts", "verify-macos-app.sh"), "utf8"),
readFile(path.join(repo, ".github", "workflows", "release.yml"), "utf8"),
]);

assert.match(config, /afterSign: scripts\/after-sign\.mjs/);
assert.match(config, /hardenedRuntime: true/);
assert.match(config, /strictVerify: true/);
assert.match(config, /notarize: false/);
assert.match(verifier, new RegExp(EXPECTED_IDENTITY.replace(/[()]/g, "\\$&")));
assert.match(packageScript, new RegExp(EXPECTED_IDENTITY_QUALIFIER.replace(/[()]/g, "\\$&")));
assert.match(packageScript, /NOTARYTOOL_KEYCHAIN_PROFILE/);
assert.match(packageScript, /tag -v/);
assert.match(verifier, /codesign --verify --strict --deep/);
assert.match(verifier, /chat\.clickclack\.desktop/);
assert.match(verifier, /FWJYW4S8P8/);
assert.match(workflow, /Verify signed macOS release assets/);
});
54 changes: 54 additions & 0 deletions apps/desktop/scripts/package-macos-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail

root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
repo=$(cd "$root/../.." && pwd)
tag=${1:-}
identity_qualifier='OpenClaw Foundation (FWJYW4S8P8)'

if [[ ! "$tag" =~ ^v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?)$ ]]; then
echo "usage: $0 vMAJOR.MINOR.PATCH" >&2
exit 2
fi
if [[ "$(uname -s)" != Darwin ]]; then
echo "official macOS packaging must run on macOS" >&2
exit 1
fi
if [[ -z "${NOTARYTOOL_KEYCHAIN_PROFILE:-}" ]]; then
echo "official macOS packaging requires NOTARYTOOL_KEYCHAIN_PROFILE" >&2
exit 1
fi

head_commit=$(git -C "$repo" rev-parse HEAD)
tag_commit=$(git -C "$repo" rev-parse "refs/tags/$tag^{commit}" 2>/dev/null) || {
echo "release tag does not exist locally: $tag" >&2
exit 1
}
if [[ "$head_commit" != "$tag_commit" ]]; then
echo "HEAD does not match release tag $tag" >&2
exit 1
fi
if [[ -n "$(git -C "$repo" status --porcelain --untracked-files=normal)" ]]; then
echo "release checkout is not clean" >&2
exit 1
fi
git -C "$repo" tag -v "$tag" >/dev/null 2>&1 || {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pin the authorized release-tag signing key

When the maintainer keyring contains a public key belonging to any contributor or other unapproved signer, git tag -v succeeds for that key's cryptographically valid signature even if the key is not trusted for releases; its help only describes -v as “verify tags,” not authorize the signer. An attacker able to publish such a tag could therefore have arbitrary code signed with the Foundation Developer ID certificate. Check the verified signer's fingerprint against an explicit release-key allowlist before packaging.

Useful? React with 👍 / 👎.

echo "release tag is not signed by a trusted git signing key: $tag" >&2
exit 1
}

version=${tag#v}
cd "$root"
pnpm build
rm -rf release
CLICKCLACK_OFFICIAL_MACOS_RELEASE=1 \
CSC_IDENTITY_AUTO_DISCOVERY=true \
CSC_NAME="$identity_qualifier" \
pnpm exec electron-builder \
--mac dmg zip --x64 --arm64 \
--config.mac.identity="$identity_qualifier" \
--config.extraMetadata.version="$version" \
--publish never

"$root/scripts/verify-macos-release.sh" "$tag" "$root/release"
node "$root/scripts/release-artifacts.mjs" mac "$version" "$root/release"
Loading