diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb55daa2..1103cfef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,6 @@ name: release on: - push: - tags: - - "v*" workflow_dispatch: inputs: tag_name: @@ -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: @@ -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 @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd9faf4..a2ec844b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index aaca53f4..b7525220 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -3,6 +3,7 @@ productName: ClickClack copyright: Copyright © OpenClaw contributors asar: true compression: normal +afterSign: scripts/after-sign.mjs directories: output: release files: @@ -23,6 +24,8 @@ mac: icon: assets/icon.icns hardenedRuntime: true gatekeeperAssess: false + strictVerify: true + notarize: false minimumSystemVersion: "12.0" target: - dmg diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1dad5326..df0a93ef 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/scripts/after-sign.mjs b/apps/desktop/scripts/after-sign.mjs new file mode 100644 index 00000000..15cd9889 --- /dev/null +++ b/apps/desktop/scripts/after-sign.mjs @@ -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)); +} diff --git a/apps/desktop/scripts/macos-signing.test.mjs b/apps/desktop/scripts/macos-signing.test.mjs new file mode 100644 index 00000000..d1a77780 --- /dev/null +++ b/apps/desktop/scripts/macos-signing.test.mjs @@ -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/); +}); diff --git a/apps/desktop/scripts/package-macos-release.sh b/apps/desktop/scripts/package-macos-release.sh new file mode 100755 index 00000000..f3d3d383 --- /dev/null +++ b/apps/desktop/scripts/package-macos-release.sh @@ -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 || { + 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" diff --git a/apps/desktop/scripts/test.mjs b/apps/desktop/scripts/test.mjs index f4042289..909d9fd5 100644 --- a/apps/desktop/scripts/test.mjs +++ b/apps/desktop/scripts/test.mjs @@ -6,6 +6,7 @@ import path from "node:path"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const outfile = path.join(root, ".test", "contract.test.cjs"); const releaseArtifactsTest = path.join(root, "scripts", "release-artifacts.test.mjs"); +const macosSigningTest = path.join(root, "scripts", "macos-signing.test.mjs"); await build({ absWorkingDir: root, @@ -18,7 +19,11 @@ await build({ target: "node22", }); -const result = spawnSync(process.execPath, ["--test", outfile, releaseArtifactsTest], { - stdio: "inherit", -}); +const result = spawnSync( + process.execPath, + ["--test", outfile, releaseArtifactsTest, macosSigningTest], + { + stdio: "inherit", + }, +); process.exit(result.status ?? 1); diff --git a/apps/desktop/scripts/verify-macos-app.sh b/apps/desktop/scripts/verify-macos-app.sh new file mode 100755 index 00000000..9aee1a7b --- /dev/null +++ b/apps/desktop/scripts/verify-macos-app.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +expected_identity='Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)' +expected_team='FWJYW4S8P8' +expected_bundle_id='chat.clickclack.desktop' +requirement="identifier \"$expected_bundle_id\" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$expected_team\"" +require_notarized=0 + +if [[ "${1:-}" == --require-notarized ]]; then + require_notarized=1 + shift +fi + +app=${1:-} +if [[ -z "$app" ]]; then + echo "usage: $0 [--require-notarized] " >&2 + exit 2 +fi +if [[ ! -d "$app" ]]; then + echo "ClickClack app bundle not found: $app" >&2 + exit 1 +fi + +plist="$app/Contents/Info.plist" +main_executable="$app/Contents/MacOS/ClickClack" +if [[ ! -f "$plist" || ! -f "$main_executable" ]]; then + echo "invalid ClickClack app layout: $app" >&2 + exit 1 +fi + +bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$plist") +version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist") +if [[ "$bundle_id" != "$expected_bundle_id" ]]; then + echo "unexpected bundle identifier: $bundle_id" >&2 + exit 1 +fi +if [[ -n "${CLICKCLACK_EXPECTED_VERSION:-}" && "$version" != "$CLICKCLACK_EXPECTED_VERSION" ]]; then + echo "unexpected bundle version: $version" >&2 + exit 1 +fi + +codesign --verify --strict --deep --verbose=2 "$app" +codesign --verify --strict -R="$requirement" --verbose=2 "$app" + +verify_signature() { + local signed_path=$1 signature + signature=$(codesign -dvvv "$signed_path" 2>&1) + grep -Fqx "Authority=$expected_identity" <<<"$signature" + grep -Fqx "TeamIdentifier=$expected_team" <<<"$signature" + grep -Eq '^CodeDirectory .*flags=.*\([^)]*runtime[^)]*\)' <<<"$signature" + if grep -Fqx 'Signature=adhoc' <<<"$signature"; then + echo "ad-hoc signature found: $signed_path" >&2 + return 1 + fi +} + +verify_signature "$app" +while IFS= read -r signed_path; do + verify_signature "$signed_path" +done < <( + find "$app/Contents/Frameworks" \ + \( -type d \( -name '*.app' -o -name '*.framework' \) \ + -o -type f \( -name '*.dylib' -o -perm -111 \) \) -print +) + +designated_requirement=$(codesign -d -r- "$app" 2>&1) +grep -F "identifier \"$expected_bundle_id\"" <<<"$designated_requirement" >/dev/null +grep -F "certificate leaf[subject.OU] = $expected_team" <<<"$designated_requirement" >/dev/null + +if [[ "$require_notarized" == 1 ]]; then + spctl --assess --type execute --verbose=4 "$app" + xcrun stapler validate "$app" + if command -v syspolicy_check >/dev/null 2>&1; then + syspolicy_check distribution "$app" + fi +fi + +echo "verified ClickClack $version: $expected_bundle_id, $expected_team, hardened runtime, sealed resources" diff --git a/apps/desktop/scripts/verify-macos-release.sh b/apps/desktop/scripts/verify-macos-release.sh new file mode 100755 index 00000000..3d2d5bde --- /dev/null +++ b/apps/desktop/scripts/verify-macos-release.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +tag=${1:-} +release_dir=${2:-"$root/release"} + +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 [release-directory]" >&2 + exit 2 +fi +if [[ "$(uname -s)" != Darwin ]]; then + echo "macOS release verification must run on macOS" >&2 + exit 1 +fi + +version=${tag#v} +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/clickclack-macos-verify.XXXXXX") +mounted_volume="" +cleanup() { + if [[ -n "$mounted_volume" ]]; then + hdiutil detach "$mounted_volume" -quiet || true + fi + rm -rf "$work_dir" +} +trap cleanup EXIT + +verify_app() { + local app=$1 expected_arch=$2 actual_archs + CLICKCLACK_EXPECTED_VERSION="$version" \ + "$root/scripts/verify-macos-app.sh" --require-notarized "$app" + actual_archs=$(lipo -archs "$app/Contents/MacOS/ClickClack") + if [[ "$actual_archs" != "$expected_arch" ]]; then + echo "unexpected ClickClack architecture: $actual_archs" >&2 + exit 1 + fi +} + +for arch in arm64 x64; do + if [[ "$arch" == arm64 ]]; then + expected_arch=arm64 + else + expected_arch=x86_64 + fi + zip="$release_dir/ClickClack-$version-mac-$arch.zip" + dmg="$release_dir/ClickClack-$version-mac-$arch.dmg" + if [[ ! -f "$zip" || ! -f "$dmg" ]]; then + echo "missing macOS release artifacts for $arch in $release_dir" >&2 + exit 1 + fi + + zip_dir="$work_dir/zip-$arch" + mkdir -p "$zip_dir" + ditto -x -k "$zip" "$zip_dir" + verify_app "$zip_dir/ClickClack.app" "$expected_arch" + + mount_dir="$work_dir/dmg-$arch" + mkdir -p "$mount_dir" + hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_dir" -quiet + mounted_volume="$mount_dir" + verify_app "$mount_dir/ClickClack.app" "$expected_arch" + hdiutil detach "$mount_dir" -quiet + mounted_volume="" +done + +echo "verified notarized ClickClack $version macOS ZIP and DMG artifacts" diff --git a/docs/desktop.md b/docs/desktop.md index 069c4565..3a57f9a9 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -113,12 +113,14 @@ pnpm --filter @clickclack/desktop run dist:win pnpm --filter @clickclack/desktop run dist:linux ``` -Pull requests run a three-platform desktop workflow and attach unsigned preview -installers for seven days. Version tags run the release workflow on native -macOS, Windows, and Linux runners, verify per-platform SHA-256 manifests, and -attach the installers before publishing the matching draft GitHub Release. -These builds are currently unsigned, so operating-system trust prompts are -expected until platform signing and notarization credentials are configured. +Pull requests run a three-platform desktop workflow and attach explicitly +unsigned preview installers for seven days. Official macOS release candidates +are built on an authorized maintainer Mac from the exact signed tag, signed +inside-out with the OpenClaw Foundation Developer ID identity and hardened +runtime, notarized, stapled, and uploaded to a private draft. The release +workflow independently verifies their checksums, bundle seals, stable bundle +identifier, Foundation team, Gatekeeper assessment, and notarization tickets +before publishing them alongside the Windows and Linux installers. ## Icon system diff --git a/docs/releasing.md b/docs/releasing.md index 8cdc2bdf..79777df9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -31,37 +31,68 @@ The snapshot build runs `pnpm build`, then cross-compiles `clickclack` for: It also emits Linux `.deb` and `.rpm` packages and `sha256sums.txt`. -The same workflow builds native desktop installers on their matching GitHub -runner. The desktop app version comes from the release tag, and each runner -emits a platform checksum manifest: +The same workflow builds Windows and Linux desktop installers on their matching +GitHub runner. The desktop app version comes from the release tag, and each +runner emits a platform checksum manifest: -- macOS: x64 and arm64 `.dmg` and `.zip` - Windows: x64 NSIS `.exe` and `.zip` - Linux: x64 `.AppImage` and `.deb` +Official macOS artifacts are built locally from the exact signed tag on an +authorized maintainer Mac. The Foundation Developer ID private key never enters +GitHub Actions. Electron Builder signs the app and all nested Electron code +inside-out with `Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)`, +enables the hardened runtime, notarizes and staples each architecture, and then +creates the x64 and arm64 `.dmg` and `.zip` files. The local verifier opens every +finished archive and requires the stable `chat.clickclack.desktop` designated +requirement, Foundation team, sealed resources, Gatekeeper acceptance, and a +valid notarization ticket. + GoReleaser leaves the GitHub Release as a draft after uploading the server -artifacts. The publish job downloads all three runner outputs, verifies every -SHA-256 manifest, attaches the installers and manifests to that draft, and only -then publishes it. A failed native build, upload, or checksum leaves a private -draft instead of exposing an incomplete release. +artifacts. The publish job downloads the Windows and Linux runner outputs, +verifies every SHA-256 manifest, and attaches them to that draft. A separate +clean macOS runner downloads the pre-uploaded macOS draft assets and repeats +their checksum, signature, Gatekeeper, and notarization verification. The draft +is published only after all of those jobs pass. -## Publish +## Build the macOS release candidates + +Create and verify the signed tag, then check it out in a clean repository. The +notary profile must already be stored in the login keychain; its credentials do +not belong in the repository. + +```sh +git tag -s v0.3.0 -m "Release v0.3.0" +git push origin main +git push origin v0.3.0 +git checkout v0.3.0 +NOTARYTOOL_KEYCHAIN_PROFILE= \ + pnpm --filter @clickclack/desktop run dist:mac:release -- v0.3.0 +``` + +The command fails closed unless `HEAD` is the clean, trusted signed tag. It +leaves the verified files and `ClickClack--mac-SHA256SUMS.txt` under +`apps/desktop/release/`. -Push a semver tag: +Create a private draft containing those files: ```sh -git tag v0.1.0 -git push origin v0.1.0 +gh release create v0.3.0 --draft --verify-tag \ + apps/desktop/release/ClickClack-0.3.0-mac-*.dmg \ + apps/desktop/release/ClickClack-0.3.0-mac-*.zip \ + apps/desktop/release/ClickClack-0.3.0-mac-SHA256SUMS.txt ``` -The release workflow checks out the tag, installs Go and pnpm, runs -`pnpm check`, sets `CLICKCLACK_WEB_VERSION` to the checked-out commit, then -runs `goreleaser release --clean` with `GITHUB_TOKEN`. In parallel, native -runners build the desktop apps. Once GoReleaser and all native builds succeed, -the verified desktop files are uploaded and the draft is published. - -Manual release dispatch is available for an existing tag through the -`release` workflow's `tag_name` input. GoReleaser reuses an existing draft and -replaces matching server assets, while the desktop uploader replaces matching -desktop assets. This makes a failed draft release safe to retry without making -published releases mutable. +## Publish + +Run the `release` workflow from protected `main` and provide the existing tag. +The workflow checks out the tag, installs Go and pnpm, runs `pnpm check`, sets +`CLICKCLACK_WEB_VERSION` to the checked-out commit, then runs +`goreleaser release --clean` with `GITHUB_TOKEN`. In parallel, native runners +build Windows and Linux desktop apps and a clean macOS runner verifies the +signed draft assets. Once every job succeeds, the verified desktop files are +attached and the draft is published. + +GoReleaser reuses the existing draft and replaces matching server assets, while +the desktop uploader replaces matching Windows and Linux assets. This makes a +failed draft release safe to retry without making published releases mutable.