From f5bfbf8a01d2d2b15da2c412e216d76fa7007d37 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 21 Oct 2026 13:47:34 -0700 Subject: [PATCH 1/9] Add local make all pipeline and Linux desktop signing Run the GitLab build graph in the pinned builder container, and package, rcodesign-sign, notarize, and DMG the macOS desktop app on Linux. iOS remains the only job that requires macOS (dispatched via MAC_HOST). --- .ci/dockerfile-hash.yaml | 2 +- Makefile | 19 +++++ docker/build.Dockerfile | 13 ++- docs/local-ci.md | 67 +++++++++++++++ hack/all.sh | 152 +++++++++++++++++++++++++++++++++++ hack/desktop-darwin-linux.sh | 66 +++++++++++++++ hack/linux-bin/codesign | 6 ++ hack/make-dmg.sh | 56 +++++++++++++ js/desktop/forge.config.ts | 106 ++++++++++++++++++++++-- 9 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 docs/local-ci.md create mode 100755 hack/all.sh create mode 100755 hack/desktop-darwin-linux.sh create mode 100755 hack/linux-bin/codesign create mode 100755 hack/make-dmg.sh diff --git a/.ci/dockerfile-hash.yaml b/.ci/dockerfile-hash.yaml index dea58d98e..9b8174432 100644 --- a/.ci/dockerfile-hash.yaml +++ b/.ci/dockerfile-hash.yaml @@ -1,2 +1,2 @@ variables: - DOCKERFILE_HASH: e1dbd7828d9948930c18903aaf44d144fb6c8d63 \ No newline at end of file + DOCKERFILE_HASH: 64e63ffdc39fd195f893959c2661e56819a940e9 \ No newline at end of file diff --git a/Makefile b/Makefile index 037b47dd7..0743585a8 100644 --- a/Makefile +++ b/Makefile @@ -547,6 +547,11 @@ link-test-windows: && exit 1; \ fi +# Run the GitLab build pipeline locally in the builder container. +.PHONY: all +all: + bash hack/all.sh + .PHONY: ci ci: version install app node-all-platforms ci-upload-node @@ -740,8 +745,22 @@ desktop-darwin-amd64: desktop-darwin-arm64: echo "TODO" +# On macOS Forge signs and notarizes with the platform tools. Elsewhere the +# postPackage hook signs with rcodesign and hack/make-dmg.sh builds the DMG. .PHONY: desktop-darwin desktop-darwin: + @if [ "$$(uname -s)" = "Darwin" ]; then \ + $(MAKE) desktop-darwin-macos; \ + else \ + $(MAKE) desktop-darwin-linux; \ + fi + +.PHONY: desktop-darwin-linux +desktop-darwin-linux: + bash hack/desktop-darwin-linux.sh + +.PHONY: desktop-darwin-macos +desktop-darwin-macos: export DEBUG="electron-osx-sign*" \ && cd js/desktop \ && pnpm run make --platform darwin --arch arm64 \ diff --git a/docker/build.Dockerfile b/docker/build.Dockerfile index cfc0022b0..1bd0bb502 100644 --- a/docker/build.Dockerfile +++ b/docker/build.Dockerfile @@ -36,13 +36,24 @@ RUN apt update \ nasm gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 mingw-w64-tools zip bison flex expect \ mono-runtime nuget mono-xsp4 squashfs-tools \ libc6:arm64 libstdc++6:arm64 \ - cmake libssl-dev libssl-dev:arm64 \ + cmake libssl-dev libssl-dev:arm64 libbz2-dev zlib1g-dev hfsprogs hfsplus \ ruby-rubygems postgresql sudo \ && pip install meson==1.11.2 tomli \ && curl -L --fail https://go.dev/dl/go$GO_VERSION.linux-$TARGETARCH.tar.gz -o go.tar.gz \ && tar -C /usr/local -xf go.tar.gz \ && rm go.tar.gz +# Builds DMGs from macOS app bundles on Linux (hack/make-dmg.sh), so the +# desktop-darwin job no longer needs a Mac. hfsprogs/hfsplus create the HFS+ +# image; libdmg-hfsplus wraps it in a UDIF. +RUN git clone https://github.com/mozilla/libdmg-hfsplus /tmp/libdmg-hfsplus \ + && cd /tmp/libdmg-hfsplus \ + && git checkout e18579b391950589b9ed16ee630c54ce4319bb29 \ + && cmake . \ + && make -j"$(nproc)" \ + && make install \ + && rm -rf /tmp/libdmg-hfsplus + RUN echo 'deb [arch=amd64,i386 signed-by=/etc/apt/keyrings/winehq-archive.key] https://storage.googleapis.com/streamplace-crap/dl.winehq.org/wine-builds/ubuntu/ jammy main' >> /etc/apt/sources.list \ && echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/llvm-snapshot.key] http://apt.llvm.org/jammy/ llvm-toolchain-jammy-21 main' >> /etc/apt/sources.list \ && apt update \ diff --git a/docs/local-ci.md b/docs/local-ci.md new file mode 100644 index 000000000..66fbac93f --- /dev/null +++ b/docs/local-ci.md @@ -0,0 +1,67 @@ +# Running the GitLab pipeline locally + +`make all` runs the build jobs from `.gitlab-ci.yml` in the pinned builder +container, in the same order the CI jobs produce them, and leaves artifacts in +`bin/`. + +```sh +make all +``` + +Run it from a Linux host (or WSL2) with Docker. On the host it calls +`hack/container.sh`; inside the builder container it runs in place. The script +is `hack/all.sh`. + +## What runs + +- `test`: `make dev`, the Go test suite with `STREAMPLACE_TEST_COUNT=1`, and a + lexicon regeneration check. The check fails only if running `make lexicons` + changes the tree, so it also works with unrelated local edits (CI's + `make ci-lexicons` compares the whole diff). The test build uses + `build-test-linux-amd64` so that it does not share a build directory with the + static release build. +- `linux-amd64`, `linux-arm64`, `windows-amd64`, `darwin-amd64`, + `darwin-arm64`: the node build, `test-vod` (where the binary can run), + `desktop-$TARGET`, and `pkg-$TARGET`. +- `android-debug` and `android-release`: `make ci-android-debug` and + `make ci-android-release`. +- `desktop-darwin`: `make desktop-darwin-linux` packages both architectures + with Electron Forge, signs each `.app` with `rcodesign` in the Forge + `postPackage` hook, and builds the DMG with `hack/make-dmg.sh` (hfsprogs + + libdmg-hfsplus). Without `DEVELOPER_CERTIFICATE_BASE64` the app is ad-hoc + signed; with `APPLE_CODESIGN_API_KEY` it is notarized and stapled. +- `build-ios`: `make ios` on a macOS machine. This is the only job that cannot + run on Linux, because it needs Xcode. + +The tag-only publishing jobs (`npm-release`, `release`, `deb-release`, +`homebrew-release`) are not run: they need CI credentials and should not fire +by accident. + +## iOS on a macOS machine + +Set `MAC_HOST` to an SSH destination whose checkout is at the same commit: + +```sh +MAC_HOST=admin@mac-mini MAC_REPO=/Users/admin/streamplace make all +``` + +The remote needs the same toolchain the CI macOS job installs (Go, Node 22, +Meson, Rust, Xcode 26). Without `MAC_HOST`, `make all` prints the skipped iOS +job and exits successfully; set `ALL_STRICT=1` to make that a failure. + +There is no way to build the iOS archive on Linux or Windows: `xcodebuild` +and the iOS SDK only exist on macOS. On an AMD/Threadripper host, the practical +option is a macOS VM (OSX-KVM/OpenCore with the AMD kernel patches) or a +physical/cloud Mac. Xcode 26.3 supports macOS Sequoia 15.6 and later, so an +x86_64 VM is sufficient; the archive is built with `CODE_SIGNING_ALLOWED=NO`, +so no Apple account is needed for the build itself. + +## Notes + +- A fresh checkout is assumed, like a CI runner. If `build-linux-amd64` was + configured for the shared dev build, `make all` reconfigures it for the + static release build. Run the release targets manually with a different + `BUILDDIR` if you need to keep that dev build intact. +- The builder image must be the one pinned in `.ci/dockerfile-hash.yaml`. + `hack/container.sh` warns if an existing container runs a different image; + recreate it after the pin changes. diff --git a/hack/all.sh b/hack/all.sh new file mode 100755 index 000000000..5a8485065 --- /dev/null +++ b/hack/all.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# +# Run the GitLab build pipeline on this machine, in the pinned builder +# container: +# +# make all +# +# This mirrors the jobs in .gitlab-ci.yml: the test job, every cross-compiled +# node target (with its desktop package), both Android APKs, the macOS desktop +# app, and iOS. Artifacts land in bin/ as the CI jobs leave them; the iOS +# archive stays on the macOS machine that builds it. +# +# The tag-only release/upload jobs (npm, GitLab release, deb, Homebrew) are +# deliberately not run: they publish and need CI credentials. +# +# iOS is the one job that cannot run on Linux. Set MAC_HOST (and optionally +# MAC_REPO) to dispatch it to a macOS machine with the same checkout; otherwise +# it is reported as skipped. Set ALL_STRICT=1 to make skips fatal. +# +# A fresh checkout is assumed, like a CI runner. `make all` reconfigures +# build-linux-amd64 for the static release build; the test job uses +# build-test-linux-amd64 so the two native builds do not fight. +set -euo pipefail + +cd "$(dirname "$0")/.." +REPO="$PWD" +NAME="$(basename "$REPO")" +TEST_BUILDDIR="${TEST_BUILDDIR:-build-test-linux-amd64}" +SKIPPED=() + +step() { + printf '\n==> %s\n' "$*" +} + +if [ "$(uname -s)" != "Linux" ]; then + echo "make all runs the Linux pipeline; run it on Linux (or in the builder container)." >&2 + exit 1 +fi + +CONTAINER="" +if [ -f /.dockerenv ] || [ -f /run/.containerenv ]; then + echo "already inside a container; running in place" +else + if [[ "$NAME" != streamplace* ]]; then + echo "refusing: $REPO is not a streamplace-* checkout" >&2 + exit 1 + fi + step "container (hack/container.sh ensure)" + bash hack/container.sh ensure + CONTAINER="$(bash hack/container.sh name)" +fi + +run() { + if [ -z "$CONTAINER" ]; then + "$@" + else + docker exec -w "$REPO" "$CONTAINER" "$@" + fi +} + +# --------------------------------------------------------------------------- +# test job +# --------------------------------------------------------------------------- +step "version and frontends" +run make version +# Build the embedded bundles from the current source; `make dev` would reuse a +# stale dist directory left by a previous branch. +run make app + +step "test (dev build and Go tests)" +# Separate build directory: the release target below wants a static +# build-linux-amd64, while dev/tests want a shared one. +run make dev BUILDDIR="$TEST_BUILDDIR" +run env STREAMPLACE_TEST_COUNT=1 make dev-test BUILDDIR="$TEST_BUILDDIR" + +step "lexicons are up to date" +# `make ci-lexicons` compares the whole tree with git, which fails on any +# unrelated local edit. Check instead that running the generator does not +# change the tree, which is the invariant CI is protecting. +tree_state() { + { git diff --binary; git status --porcelain; } | git hash-object --stdin +} +LEXICON_STATE="$(tree_state)" +run make lexicons +if [ "$(tree_state)" != "$LEXICON_STATE" ]; then + echo "lexicons are out of date; run make lexicons and commit the generated files" >&2 + git diff + exit 1 +fi + +# --------------------------------------------------------------------------- +# node jobs: build the target, then its desktop package and distro package +# --------------------------------------------------------------------------- +target() { + local target="$1" + step "$target" + run make "$target" + # test-vod only runs on the build host, so in CI it is a no-op for the + # cross-compiled jobs. Here the linux-amd64 binary exists, so run it once. + if [ "$target" = "linux-amd64" ]; then + run make test-vod + fi + run make "desktop-$target" + run make "pkg-$target" +} + +target linux-amd64 +target linux-arm64 +target windows-amd64 +target darwin-amd64 +target darwin-arm64 + +# --------------------------------------------------------------------------- +# android jobs +# --------------------------------------------------------------------------- +step "android-debug" +run make ci-android-debug + +step "android-release" +run make ci-android-release + +# --------------------------------------------------------------------------- +# macOS desktop app (packages and signs with rcodesign on Linux) +# --------------------------------------------------------------------------- +step "desktop-darwin" +run make desktop-darwin-linux + +# --------------------------------------------------------------------------- +# iOS: requires Xcode, so it stays on macOS +# --------------------------------------------------------------------------- +if [ -n "${MAC_HOST:-}" ]; then + step "ios on $MAC_HOST" + COMMIT="$(git rev-parse HEAD)" + MAC_REPO="${MAC_REPO:-$REPO}" + # The remote checkout must be at the same commit; --make all does not push. + ssh "$MAC_HOST" "cd '$MAC_REPO' && test \"\$(git rev-parse HEAD)\" = '$COMMIT' && make version install app ios" +else + SKIPPED+=("build-ios: needs macOS; set MAC_HOST=user@host (and MAC_REPO=/path/to/checkout)") +fi + +# --------------------------------------------------------------------------- +# report +# --------------------------------------------------------------------------- +printf '\n' +if [ "${#SKIPPED[@]}" -gt 0 ]; then + printf 'skipped (cannot run on this host):\n' + printf ' - %s\n' "${SKIPPED[@]}" + if [ "${ALL_STRICT:-0}" = "1" ]; then + exit 1 + fi +fi +echo "make all: done" diff --git a/hack/desktop-darwin-linux.sh b/hack/desktop-darwin-linux.sh new file mode 100755 index 000000000..69ae154e8 --- /dev/null +++ b/hack/desktop-darwin-linux.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Build the macOS desktop app (both architectures) on Linux. +# +# Electron Forge can package and make the darwin zip here; the forge config +# signs each .app with rcodesign in its postPackage hook and leaves the DMG to +# hack/make-dmg.sh (MakerDMG needs hdiutil). Signing credentials come from the +# same environment as the Go binaries: DEVELOPER_CERTIFICATE_BASE64, +# DEVELOPER_CERTIFICATE_PASSWORD, and APPLE_CODESIGN_API_KEY for notarization. +# Without a certificate the app is ad-hoc signed, like `make darwin-*`. +set -euo pipefail + +cd "$(dirname "$0")/.." +REPO="$PWD" + +for tool in rcodesign zip dmg mkfs.hfsplus hfsplus; do + if ! command -v "$tool" >/dev/null; then + echo "$tool not found; the builder image is stale (recreate the container" >&2 + echo "after .ci/dockerfile-hash.yaml changes) or this is not the container." >&2 + exit 1 + fi +done + +# @electron/fuses resets the ad-hoc signature after flipping fuses by invoking +# `codesign`. There is no Apple codesign here, and the postPackage hook signs +# the bundle with rcodesign afterwards, so make that one call a no-op. +export PATH="$REPO/hack/linux-bin:$PATH" + +for arch in amd64 arm64; do + if [ ! -x "build-darwin-$arch/streamplace" ]; then + echo "=== building darwin-$arch binary ===" + make "darwin-$arch" + fi +done + +VERSION="$(go run ./pkg/config/git/git.go -v)" +if [ -z "${DEVELOPER_CERTIFICATE_BASE64:-}" ]; then + echo "DEVELOPER_CERTIFICATE_BASE64 unset: building an ad-hoc-signed app" +fi + +mkdir -p bin + +for arch in amd64 arm64; do + case "$arch" in + amd64) forge_arch=x64 ;; + arm64) forge_arch=arm64 ;; + esac + + echo "=== packaging darwin-$arch ===" + rm -rf "js/desktop/out/make/zip/darwin/$forge_arch" + ( + cd js/desktop + pnpm run make --platform darwin --arch "$forge_arch" + ) + + zip_file="$(find "js/desktop/out/make/zip/darwin/$forge_arch" -maxdepth 1 -name '*.zip' -print -quit)" + if [ -z "$zip_file" ]; then + echo "no zip produced for darwin-$arch" >&2 + exit 1 + fi + mv "$zip_file" "bin/streamplace-desktop-$VERSION-darwin-$arch.zip" + + app="js/desktop/out/Streamplace-darwin-$forge_arch/Streamplace.app" + hack/make-dmg.sh "$app" "Streamplace $VERSION" \ + "bin/streamplace-desktop-$VERSION-darwin-$arch.dmg" +done diff --git a/hack/linux-bin/codesign b/hack/linux-bin/codesign new file mode 100755 index 000000000..4be9fb7ea --- /dev/null +++ b/hack/linux-bin/codesign @@ -0,0 +1,6 @@ +#!/bin/sh +# @electron/fuses calls `codesign` to reset the ad-hoc signature after flipping +# fuses. On Linux the packaged .app is signed afterwards by rcodesign in the +# Forge postPackage hook, so there is nothing to do here. Keep this on PATH +# (see hack/desktop-darwin-linux.sh) only while packaging for darwin. +exit 0 diff --git a/hack/make-dmg.sh b/hack/make-dmg.sh new file mode 100755 index 000000000..00098525c --- /dev/null +++ b/hack/make-dmg.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# Build a macOS .dmg from a .app bundle on Linux, without hdiutil. +# +# hack/make-dmg.sh +# +# Uses hfsprogs + hfsplus to create an HFS+ image and mozilla/libdmg-hfsplus +# (`dmg build`) to convert it to UDIF. Both are installed in the builder image. +# +# This produces a functional image (the app plus an /Applications symlink) but +# no Finder background or icon placement: those live in a .DS_Store generated +# by hdiutil, which is macOS-only. +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +APP="$1" +VOLUME="$2" +OUT="$3" + +for tool in mkfs.hfsplus hfsplus dmg; do + if ! command -v "$tool" >/dev/null; then + echo "$tool not found; the builder image is stale (recreate the container" >&2 + echo "after .ci/dockerfile-hash.yaml changes) or this is not the container." >&2 + exit 1 + fi +done + +if [ ! -d "$APP" ]; then + echo "$APP is not a directory" >&2 + exit 1 +fi + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/streamplace-dmg.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +ROOT="$TMP/root" +mkdir -p "$ROOT" +# -a preserves the nested framework symlinks and executable modes; the code +# signature is embedded in the Mach-O files, so nothing else needs carrying. +cp -a "$APP" "$ROOT/" +ln -s /Applications "$ROOT/Applications" + +USED_KB="$(du -sk "$ROOT" | cut -f1)" +SIZE_MB=$(( USED_KB / 1024 + 64 )) + +dd if=/dev/zero of="$TMP/image.img" bs=1M count="$SIZE_MB" status=none +mkfs.hfsplus -v "$VOLUME" "$TMP/image.img" >/dev/null +hfsplus "$TMP/image.img" addall "$ROOT" -s clone_link >/dev/null + +mkdir -p "$(dirname "$OUT")" +rm -f "$OUT" +dmg build "$TMP/image.img" "$OUT" >/dev/null diff --git a/js/desktop/forge.config.ts b/js/desktop/forge.config.ts index 3c1157d5f..76f7721c6 100644 --- a/js/desktop/forge.config.ts +++ b/js/desktop/forge.config.ts @@ -15,9 +15,79 @@ import { FuseV1Options, FuseVersion } from "@electron/fuses"; import { MakerAppImage } from "@reforged/maker-appimage"; import child_process from "child_process"; import fs from "fs"; +import os from "os"; +import path from "path"; import { mainConfig } from "./webpack.main.config"; import { rendererConfig } from "./webpack.renderer.config"; +// Sign (and optionally notarize) a packaged macOS .app on a non-macOS host. +// Electron Forge's built-in osxSign/osxNotarize shells out to Apple's codesign +// and notarytool, so Linux uses rcodesign (apple-codesign) instead. The bundle +// is signed recursively, including the nested Electron helper apps/frameworks. +function signDarwinApp(appPath: string) { + const args = [ + "sign", + "--code-signature-flags", + "runtime", + "--entitlements-xml-path", + path.join(__dirname, "entitlements.plist"), + ]; + let p12Dir: string | undefined; + try { + if (process.env.DEVELOPER_CERTIFICATE_BASE64) { + p12Dir = fs.mkdtempSync(path.join(os.tmpdir(), "streamplace-p12-")); + const p12File = path.join(p12Dir, "certificate.p12"); + fs.writeFileSync( + p12File, + Buffer.from(process.env.DEVELOPER_CERTIFICATE_BASE64, "base64"), + ); + args.push( + "--p12-file", + p12File, + "--p12-password", + process.env.DEVELOPER_CERTIFICATE_PASSWORD ?? "", + ); + } + args.push(appPath); + console.log(`signing ${appPath} with rcodesign`); + child_process.execFileSync("rcodesign", args, { stdio: "inherit" }); + } finally { + if (p12Dir) fs.rmSync(p12Dir, { recursive: true, force: true }); + } + + if (process.env.APPLE_CODESIGN_API_KEY) { + // Apple only accepts archives, so submit a zip and then staple the ticket + // to the bundle that the makers distribute. + const notarizeDir = fs.mkdtempSync( + path.join(os.tmpdir(), "streamplace-notarize-"), + ); + try { + const zipPath = path.join(notarizeDir, "Streamplace.zip"); + child_process.execFileSync( + "zip", + ["-qry", zipPath, path.basename(appPath)], + { cwd: path.dirname(appPath), stdio: "inherit" }, + ); + child_process.execFileSync( + "rcodesign", + [ + "notary-submit", + "--api-key-file", + process.env.APPLE_CODESIGN_API_KEY, + "--wait", + zipPath, + ], + { stdio: "inherit" }, + ); + child_process.execFileSync("rcodesign", ["staple", appPath], { + stdio: "inherit", + }); + } finally { + fs.rmSync(notarizeDir, { recursive: true, force: true }); + } + } +} + export default async function () { // go get the version from the actual script let version = child_process @@ -85,6 +155,22 @@ export default async function () { packageJson.version = version; return packageJson; }, + postPackage: async (_config, packageResult) => { + // macOS uses the built-in osxSign/osxNotarize above. Elsewhere (the + // builder container) sign each .app with rcodesign so fuses, signing, + // and notarization all still happen before the makers run. + if (process.platform === "darwin") { + return; + } + for (const outputPath of packageResult.outputPaths) { + const app = fs + .readdirSync(outputPath) + .find((entry) => entry.endsWith(".app")); + if (app) { + signDarwinApp(path.join(outputPath, app)); + } + } + }, }, rebuildConfig: {}, makers: [ @@ -93,13 +179,19 @@ export default async function () { "https://git.stream.place/-/project/1/uploads/2e5899ffd2b4799ce661cf9b8675e610/streamplace-logo-256.ico", setupIcon: "./assets/images/streamplace-logo.ico", }), - new MakerDMG( - { - icon: "./assets/images/streamplace-logo.icns", - appPath: "./Streamplace.app", - }, - ["darwin"], - ), + // @electron-forge/maker-dmg requires hdiutil, so non-macOS hosts build + // the DMG from the signed bundle with hack/make-dmg.sh instead. + ...(process.platform === "darwin" + ? [ + new MakerDMG( + { + icon: "./assets/images/streamplace-logo.icns", + appPath: "./Streamplace.app", + }, + ["darwin"], + ), + ] + : []), new MakerZIP({}, ["darwin"]), // new MakerRpm({}), new MakerAppImage({ -- knotmirror 0.0.1 From 8831bf81b5df3f215cc4ad9040dfdfb267b9f9fd Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 21 Oct 2026 13:57:07 -0700 Subject: [PATCH 2/9] Split macOS building from signing Add make build-signable (unsigned darwin binaries and Electron bundles in a transport tarball) and make sign (verify, rcodesign-sign/notarize/staple, then produce the release archives). Factor app signing into hack/rcodesign-app.sh so the Forge hook and the signing machine share it, and support timestamp-less offline signing. --- Makefile | 41 +++++++++--- docs/local-ci.md | 52 +++++++++++++++ hack/all.sh | 22 +------ hack/lib/container.sh | 28 ++++++++ hack/make-dmg.sh | 45 ++++++++----- hack/rcodesign-app.sh | 95 +++++++++++++++++++++++++++ hack/sign-input.sh | 87 ++++++++++++++++++++++++ hack/sign-release.sh | 131 +++++++++++++++++++++++++++++++++++++ js/desktop/forge.config.ts | 82 ++++------------------- util/osxcross-codesign.sh | 72 +++++++++++++++++--- 10 files changed, 526 insertions(+), 129 deletions(-) create mode 100644 hack/lib/container.sh create mode 100755 hack/rcodesign-app.sh create mode 100755 hack/sign-input.sh create mode 100755 hack/sign-release.sh diff --git a/Makefile b/Makefile index 0743585a8..f2be3f010 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,8 @@ $(shell mkdir -p $(OUT_DIR)) .PHONY: default default: streamplace +# build-signable sets this so the darwin targets stop after compiling. +SKIP_SIGN?=0 VERSION?=$(shell go run ./pkg/config/git/git.go -v) VERSION_NO_V=$(subst v,,$(VERSION)) VERSION_ELECTRON=$(subst -,-z,$(subst v,,$(VERSION))) @@ -552,6 +554,17 @@ link-test-windows: all: bash hack/all.sh +# Split build/sign workflow: build the macOS inputs here, sign them wherever +# the Developer ID certificate lives, then publish from bin/. +SIGN_INPUT?= +.PHONY: build-signable +build-signable: + bash hack/sign-input.sh + +.PHONY: sign +sign: + bash hack/sign-release.sh "$(SIGN_INPUT)" + .PHONY: ci ci: version install app node-all-platforms ci-upload-node @@ -707,11 +720,15 @@ darwin-amd64: && export MESON_SETUP_OPTS="--cross-file util/osxcross-darwin-amd64.ini" \ && export BUILDDIR=build-darwin-amd64 \ && $(MAKE) -j $(shell nproc) streamplace \ - && ./util/osxcross-codesign.sh ./build-darwin-amd64/streamplace \ - && mkdir -p bin \ - && cd build-darwin-amd64 \ - && tar -czvf ../bin/streamplace-$(VERSION)-darwin-amd64.tar.gz ./streamplace \ - && cd - + && if [ "$(SKIP_SIGN)" = "1" ]; then \ + echo "SKIP_SIGN=1: leaving build-darwin-amd64/streamplace unsigned"; \ + else \ + ./util/osxcross-codesign.sh ./build-darwin-amd64/streamplace \ + && mkdir -p bin \ + && cd build-darwin-amd64 \ + && tar -czvf ../bin/streamplace-$(VERSION)-darwin-amd64.tar.gz ./streamplace \ + && cd -; \ + fi .PHONY: darwin-arm64 darwin-arm64: @@ -726,11 +743,15 @@ darwin-arm64: && export MESON_SETUP_OPTS="--cross-file util/osxcross-darwin-arm64.ini" \ && export BUILDDIR=build-darwin-arm64 \ && $(MAKE) -j $(shell nproc) streamplace \ - && ./util/osxcross-codesign.sh ./build-darwin-arm64/streamplace \ - && mkdir -p bin \ - && cd build-darwin-arm64 \ - && tar -czvf ../bin/streamplace-$(VERSION)-darwin-arm64.tar.gz ./streamplace \ - && cd - + && if [ "$(SKIP_SIGN)" = "1" ]; then \ + echo "SKIP_SIGN=1: leaving build-darwin-arm64/streamplace unsigned"; \ + else \ + ./util/osxcross-codesign.sh ./build-darwin-arm64/streamplace \ + && mkdir -p bin \ + && cd build-darwin-arm64 \ + && tar -czvf ../bin/streamplace-$(VERSION)-darwin-arm64.tar.gz ./streamplace \ + && cd -; \ + fi # unbuffer here is a workaround for wine trying to pop up a terminal window and failing .PHONY: windows-amd64-startup-test diff --git a/docs/local-ci.md b/docs/local-ci.md index 66fbac93f..a7199ac92 100644 --- a/docs/local-ci.md +++ b/docs/local-ci.md @@ -37,6 +37,58 @@ The tag-only publishing jobs (`npm-release`, `release`, `deb-release`, `homebrew-release`) are not run: they need CI credentials and should not fire by accident. +## Building and signing separately + +The macOS artifacts need the Developer ID certificate after they are compiled: +both the Go binary and every Mach-O inside the Electron app gain a signature. +To keep the certificate off the build machine: + +```sh +# Big machine (builder container): +make build-signable + +# Signing machine (certificate + rcodesign): +make sign SIGN_INPUT=dist/streamplace--sign-input.tar.gz +``` + +`build-signable` compiles the two darwin binaries with `SKIP_SIGN=1`, packages +both Electron `.app` bundles with `STREAMPLACE_SKIP_SIGNING=1`, and writes +`dist/streamplace--sign-input.tar.gz` plus a `.sha256`. The tarball +contains the unsigned binaries, unsigned app bundles, `VERSION`, `COMMIT`, a +`SHA256SUMS` file, and a `SYMLINKS` listing. The build machine does not need +any Apple credentials. + +`sign` verifies `SHA256SUMS` and `SYMLINKS`, signs each binary and app with +`rcodesign`, optionally notarizes and staples, and writes the release artifacts +to `bin/`: + +- `streamplace--darwin-{amd64,arm64}.tar.gz` +- `streamplace-desktop--darwin-{amd64,arm64}.{zip,dmg}` +- `streamplace--SHA256SUMS` + +Without `SIGN_INPUT`, `make sign` signs the in-place `build-darwin-*` and +`js/desktop/out` trees (that mode needs Go to compute the version). The signing +machine needs the checkout for `hack/` and `js/desktop/entitlements.plist`. + +Signing offline is supported with `SIGN_OFFLINE=1`. Note that Apple notarization +requires a secure timestamp from Apple's timestamp server, so an offline +signature is **not** notarizable without re-signing with the certificate while +online. If the signing machine can reach Apple, leave `SIGN_OFFLINE` unset and +set `APPLE_CODESIGN_API_KEY` to notarize in the same pass. + +Relevant environment: + +- `DEVELOPER_CERTIFICATE_BASE64`, `DEVELOPER_CERTIFICATE_PASSWORD` — required + for a real signature; `ALLOW_ADHOC=1` permits an ad-hoc build instead. +- `APPLE_CODESIGN_API_KEY` — App Store Connect API key JSON, enables + notarization (`SIGN_NOTARIZE=auto` by default). +- `SIGN_NOTARIZE=0|1|auto` — override notarization. +- `SIGN_TIMESTAMP_URL` — rcodesign timestamp server; `none` disables it. +- `SIGN_OFFLINE=1` — implies `SIGN_TIMESTAMP_URL=none` and `SIGN_NOTARIZE=0`. + +`make all` still builds and signs in one go on the build machine; the split is +for moving the certificate to a separate environment. + ## iOS on a macOS machine Set `MAC_HOST` to an SSH destination whose checkout is at the same commit: diff --git a/hack/all.sh b/hack/all.sh index 5a8485065..894a66c33 100755 --- a/hack/all.sh +++ b/hack/all.sh @@ -24,7 +24,6 @@ set -euo pipefail cd "$(dirname "$0")/.." REPO="$PWD" -NAME="$(basename "$REPO")" TEST_BUILDDIR="${TEST_BUILDDIR:-build-test-linux-amd64}" SKIPPED=() @@ -37,26 +36,7 @@ if [ "$(uname -s)" != "Linux" ]; then exit 1 fi -CONTAINER="" -if [ -f /.dockerenv ] || [ -f /run/.containerenv ]; then - echo "already inside a container; running in place" -else - if [[ "$NAME" != streamplace* ]]; then - echo "refusing: $REPO is not a streamplace-* checkout" >&2 - exit 1 - fi - step "container (hack/container.sh ensure)" - bash hack/container.sh ensure - CONTAINER="$(bash hack/container.sh name)" -fi - -run() { - if [ -z "$CONTAINER" ]; then - "$@" - else - docker exec -w "$REPO" "$CONTAINER" "$@" - fi -} +. hack/lib/container.sh # --------------------------------------------------------------------------- # test job diff --git a/hack/lib/container.sh b/hack/lib/container.sh new file mode 100644 index 000000000..9aa263670 --- /dev/null +++ b/hack/lib/container.sh @@ -0,0 +1,28 @@ +# shellcheck shell=bash +# Source from a repo-root script to run commands in the checkout's pinned +# builder container. Sets REPO, NAME, and CONTAINER and defines run(). +# +# The caller is expected to have `set -euo pipefail` and to have changed to the +# repo root; REPO may be preset. +REPO="${REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +NAME="$(basename "$REPO")" +CONTAINER="" + +if [ -f /.dockerenv ] || [ -f /run/.containerenv ]; then + echo "already inside a container; running in place" +else + if [[ "$NAME" != streamplace* ]]; then + echo "refusing: $REPO is not a streamplace-* checkout" >&2 + return 1 + fi + bash "$REPO/hack/container.sh" ensure + CONTAINER="$(bash "$REPO/hack/container.sh" name)" +fi + +run() { + if [ -z "$CONTAINER" ]; then + "$@" + else + docker exec -w "$REPO" "$CONTAINER" "$@" + fi +} diff --git a/hack/make-dmg.sh b/hack/make-dmg.sh index 00098525c..0ac186961 100755 --- a/hack/make-dmg.sh +++ b/hack/make-dmg.sh @@ -1,15 +1,16 @@ #!/usr/bin/env bash # -# Build a macOS .dmg from a .app bundle on Linux, without hdiutil. +# Build a macOS .dmg from a .app bundle. # # hack/make-dmg.sh # -# Uses hfsprogs + hfsplus to create an HFS+ image and mozilla/libdmg-hfsplus -# (`dmg build`) to convert it to UDIF. Both are installed in the builder image. +# On macOS this uses hdiutil. On Linux it uses hfsprogs + hfsplus to create an +# HFS+ image and mozilla/libdmg-hfsplus (`dmg build`) to convert it to UDIF; +# both are installed in the builder image. # -# This produces a functional image (the app plus an /Applications symlink) but -# no Finder background or icon placement: those live in a .DS_Store generated -# by hdiutil, which is macOS-only. +# The Linux image is functional (the app plus an /Applications symlink) but +# has no Finder background or icon placement: those live in a .DS_Store +# generated by hdiutil, which is macOS-only. set -euo pipefail if [ "$#" -ne 3 ]; then @@ -21,14 +22,6 @@ APP="$1" VOLUME="$2" OUT="$3" -for tool in mkfs.hfsplus hfsplus dmg; do - if ! command -v "$tool" >/dev/null; then - echo "$tool not found; the builder image is stale (recreate the container" >&2 - echo "after .ci/dockerfile-hash.yaml changes) or this is not the container." >&2 - exit 1 - fi -done - if [ ! -d "$APP" ]; then echo "$APP is not a directory" >&2 exit 1 @@ -44,13 +37,31 @@ mkdir -p "$ROOT" cp -a "$APP" "$ROOT/" ln -s /Applications "$ROOT/Applications" +mkdir -p "$(dirname "$OUT")" +rm -f "$OUT" + +if command -v hdiutil >/dev/null; then + hdiutil create \ + -volname "$VOLUME" \ + -srcfolder "$ROOT" \ + -ov \ + -format UDZO \ + "$OUT" >/dev/null + exit 0 +fi + +for tool in mkfs.hfsplus hfsplus dmg; do + if ! command -v "$tool" >/dev/null; then + echo "$tool not found; the builder image is stale (recreate the container" >&2 + echo "after .ci/dockerfile-hash.yaml changes) or this is not the container." >&2 + exit 1 + fi +done + USED_KB="$(du -sk "$ROOT" | cut -f1)" SIZE_MB=$(( USED_KB / 1024 + 64 )) dd if=/dev/zero of="$TMP/image.img" bs=1M count="$SIZE_MB" status=none mkfs.hfsplus -v "$VOLUME" "$TMP/image.img" >/dev/null hfsplus "$TMP/image.img" addall "$ROOT" -s clone_link >/dev/null - -mkdir -p "$(dirname "$OUT")" -rm -f "$OUT" dmg build "$TMP/image.img" "$OUT" >/dev/null diff --git a/hack/rcodesign-app.sh b/hack/rcodesign-app.sh new file mode 100755 index 000000000..ec6fb1a1e --- /dev/null +++ b/hack/rcodesign-app.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Sign a packaged macOS .app with rcodesign, optionally notarize and staple it. +# +# hack/rcodesign-app.sh path/to/Streamplace.app +# +# Called by the Electron Forge postPackage hook (all-in-one desktop-darwin) and +# by hack/sign-release.sh (split build/sign workflow), so both use the same +# recursive signing and notarization logic. +# +# Environment: +# DEVELOPER_CERTIFICATE_BASE64, DEVELOPER_CERTIFICATE_PASSWORD +# Developer ID certificate. Without it rcodesign makes an ad-hoc +# signature, which macOS will not trust for distribution. +# APPLE_CODESIGN_API_KEY +# Path to an App Store Connect API key JSON, for notarization. +# SIGN_NOTARIZE +# auto (default: notarize when APPLE_CODESIGN_API_KEY is set), 1, or 0. +# SIGN_TIMESTAMP_URL +# rcodesign timestamp server URL. "none" signs without a secure +# timestamp. Apple requires a timestamp for notarization. +# SIGN_OFFLINE +# 1 implies SIGN_TIMESTAMP_URL=none and SIGN_NOTARIZE=0. Such a signature +# cannot be notarized until it is re-signed with a timestamp. +set -euo pipefail + +APP="${1:?usage: $0 }" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENTITLEMENTS="${ENTITLEMENTS:-$ROOT/js/desktop/entitlements.plist}" + +if [ "${SIGN_OFFLINE:-0}" = "1" ]; then + SIGN_TIMESTAMP_URL="${SIGN_TIMESTAMP_URL:-none}" + SIGN_NOTARIZE="${SIGN_NOTARIZE:-0}" +fi + +TIMESTAMP_ARGS=() +if [ -n "${SIGN_TIMESTAMP_URL:-}" ]; then + TIMESTAMP_ARGS=(--timestamp-url "$SIGN_TIMESTAMP_URL") +fi + +P12_DIR="" +NOTARIZE_DIR="" +cleanup() { + if [ -n "$P12_DIR" ]; then rm -rf "$P12_DIR"; fi + if [ -n "$NOTARIZE_DIR" ]; then rm -rf "$NOTARIZE_DIR"; fi +} +trap cleanup EXIT + +CERT_ARGS=() +if [ -n "${DEVELOPER_CERTIFICATE_BASE64:-}" ]; then + P12_DIR="$(mktemp -d "${TMPDIR:-/tmp}/streamplace-p12.XXXXXX")" + printf '%s' "$DEVELOPER_CERTIFICATE_BASE64" | base64 -d > "$P12_DIR/certificate.p12" + CERT_ARGS=(--p12-file "$P12_DIR/certificate.p12" --p12-password "${DEVELOPER_CERTIFICATE_PASSWORD:-}") +fi + +echo "signing $APP with rcodesign" +rcodesign sign \ + --code-signature-flags runtime \ + --entitlements-xml-path "$ENTITLEMENTS" \ + ${TIMESTAMP_ARGS[@]+"${TIMESTAMP_ARGS[@]}"} \ + ${CERT_ARGS[@]+"${CERT_ARGS[@]}"} \ + "$APP" + +NOTARIZE="${SIGN_NOTARIZE:-auto}" +if [ "$NOTARIZE" = "auto" ]; then + if [ -n "${APPLE_CODESIGN_API_KEY:-}" ]; then + NOTARIZE=1 + else + NOTARIZE=0 + fi +fi +if [ "$NOTARIZE" = "1" ]; then + if [ -z "${APPLE_CODESIGN_API_KEY:-}" ]; then + echo "APPLE_CODESIGN_API_KEY is required for notarization" >&2 + exit 1 + fi + if [ "${SIGN_TIMESTAMP_URL:-http://timestamp.apple.com/ts01}" = "none" ]; then + echo "refusing to notarize an untimestamped signature (SIGN_TIMESTAMP_URL=none)" >&2 + exit 1 + fi + # Apple only accepts archives; submit a zip, then staple the bundle that the + # makers distribute. + NOTARIZE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/streamplace-notarize.XXXXXX")" + ( + cd "$(dirname "$APP")" + zip -qry "$NOTARIZE_DIR/Streamplace.zip" "$(basename "$APP")" + ) + rcodesign notary-submit \ + --api-key-file "$APPLE_CODESIGN_API_KEY" \ + --wait \ + "$NOTARIZE_DIR/Streamplace.zip" + rcodesign staple "$APP" + rm -rf "$NOTARIZE_DIR" + NOTARIZE_DIR="" +fi diff --git a/hack/sign-input.sh b/hack/sign-input.sh new file mode 100755 index 000000000..bfbc731e2 --- /dev/null +++ b/hack/sign-input.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# Build the macOS inputs for the split build/sign workflow: +# +# make build-signable +# +# Produces unsigned darwin binaries and unsigned Electron .app bundles, then +# packs them into dist/streamplace--sign-input.tar.gz. Carry that file +# (and this checkout at the recorded commit) to the machine with the Developer +# ID certificate, then run `make sign SIGN_INPUT=...` there. +# +# Nothing here needs signing credentials. +set -euo pipefail + +cd "$(dirname "$0")/.." +REPO="$PWD" +OUT="$REPO/dist/sign-input" + +if [ "$(uname -s)" != "Linux" ]; then + echo "build-signable runs the Linux build; use the big machine or its container." >&2 + exit 1 +fi + +. hack/lib/container.sh + +step() { + printf '\n==> %s\n' "$*" +} + +step "version and frontends" +run make version +run make app + +step "darwin binaries (unsigned)" +for arch in amd64 arm64; do + run make "darwin-$arch" SKIP_SIGN=1 +done + +step "desktop app bundles (unsigned)" +for arch in amd64 arm64; do + case "$arch" in + amd64) forge_arch=x64 ;; + arm64) forge_arch=arm64 ;; + esac + run bash -c "cd '$REPO/js/desktop' && PATH='$REPO/hack/linux-bin:'\$PATH STREAMPLACE_SKIP_SIGNING=1 pnpm run package --platform darwin --arch '$forge_arch'" +done + +step "assemble $OUT" +VERSION="$(run go run ./pkg/config/git/git.go -v)" +COMMIT="$(run git rev-parse HEAD)" +rm -rf "$OUT" +mkdir -p "$OUT" +for arch in amd64 arm64; do + case "$arch" in + amd64) forge_arch=x64 ;; + arm64) forge_arch=arm64 ;; + esac + mkdir -p "$OUT/darwin-$arch" + cp "build-darwin-$arch/streamplace" "$OUT/darwin-$arch/streamplace" + cp -a "js/desktop/out/Streamplace-darwin-$forge_arch/Streamplace.app" \ + "$OUT/darwin-$arch/Streamplace.app" +done +printf '%s\n' "$VERSION" >"$OUT/VERSION" +printf '%s\n' "$COMMIT" >"$OUT/COMMIT" + +# Standard checksums cover regular files; symlinks are listed separately. The +# signing side verifies both before it signs anything. +( + cd "$OUT" + find . -type f ! -name SHA256SUMS ! -name SYMLINKS -print | + LC_ALL=C sort | + while IFS= read -r f; do sha256sum "$f"; done >SHA256SUMS + find . -type l -print | + LC_ALL=C sort | + while IFS= read -r l; do printf '%s -> %s\n' "$l" "$(readlink "$l")"; done >SYMLINKS +) + +step "transport tarball" +mkdir -p "$REPO/dist" +TARBALL="$REPO/dist/streamplace-$VERSION-sign-input.tar.gz" +tar -C "$OUT" -czf "$TARBALL" . +sha256sum "$TARBALL" >"$TARBALL.sha256" + +echo +echo "unsigned inputs: $OUT" +echo "transport: $TARBALL" +cat "$TARBALL.sha256" diff --git a/hack/sign-release.sh b/hack/sign-release.sh new file mode 100755 index 000000000..9a53fd200 --- /dev/null +++ b/hack/sign-release.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# +# Sign the macOS inputs produced by `make build-signable` and write the final +# release artifacts to bin/: +# +# make sign SIGN_INPUT=dist/streamplace--sign-input.tar.gz +# +# With no SIGN_INPUT, signs the in-place build tree (build-darwin-*/streamplace +# and js/desktop/out/Streamplace-darwin-*/Streamplace.app). +# +# This machine needs rcodesign and, for the DMG, either hdiutil (macOS) or +# hfsprogs/hfsplus/libdmg-hfsplus (Linux builder image). It does not need Go, +# Node, Meson, or the rest of the build toolchain when SIGN_INPUT is used. +# +# Credentials (see hack/rcodesign-app.sh and util/osxcross-codesign.sh): +# DEVELOPER_CERTIFICATE_BASE64, DEVELOPER_CERTIFICATE_PASSWORD +# APPLE_CODESIGN_API_KEY notarization; requires a timestamp server +# SIGN_TIMESTAMP_URL "none" for a truly offline sign +# SIGN_OFFLINE=1 sign without timestamp/notarization +# ALLOW_ADHOC=1 allow an unsigned/ad-hoc release +set -euo pipefail + +cd "$(dirname "$0")/.." +REPO="$PWD" +INPUT="${1:-${SIGN_INPUT:-}}" +WORK="" +OUT_DIR="$REPO/bin" + +cleanup() { + if [ -n "$WORK" ]; then rm -rf "$WORK"; fi +} +trap cleanup EXIT + +if [ -z "${DEVELOPER_CERTIFICATE_BASE64:-}" ] && [ "${ALLOW_ADHOC:-0}" != "1" ]; then + echo "DEVELOPER_CERTIFICATE_BASE64 is not set; set ALLOW_ADHOC=1 for an ad-hoc build" >&2 + exit 1 +fi + +if command -v sha256sum >/dev/null; then + HASH=(sha256sum) +else + HASH=(shasum -a 256) +fi + +if [ -n "$INPUT" ]; then + if [ ! -f "$INPUT" ]; then + echo "$INPUT not found" >&2 + exit 1 + fi + WORK="$(mktemp -d "${TMPDIR:-/tmp}/streamplace-sign.XXXXXX")" + tar -xzf "$INPUT" -C "$WORK" + SIGN_ROOT="$WORK" + + echo "=== verifying $INPUT ===" + if [ "${HASH[0]}" = "sha256sum" ]; then + ( cd "$SIGN_ROOT" && sha256sum -c SHA256SUMS ) + else + ( cd "$SIGN_ROOT" && shasum -a 256 -c SHA256SUMS ) + fi + ( + cd "$SIGN_ROOT" + find . -type l -print | + LC_ALL=C sort | + while IFS= read -r l; do printf '%s -> %s\n' "$l" "$(readlink "$l")"; done >SYMLINKS.actual + diff -u SYMLINKS SYMLINKS.actual + rm -f SYMLINKS.actual + ) + VERSION="$(cat "$SIGN_ROOT/VERSION")" + BUILD_COMMIT="$(cat "$SIGN_ROOT/COMMIT")" + if [ "$BUILD_COMMIT" != "$(git rev-parse HEAD)" ]; then + echo "warning: input was built at $BUILD_COMMIT but this checkout is at $(git rev-parse HEAD)" >&2 + echo "warning: signing uses this checkout's entitlements and hack/ scripts" >&2 + fi +else + SIGN_ROOT="$REPO" + VERSION="$(go run ./pkg/config/git/git.go -v)" +fi + +mkdir -p "$OUT_DIR" +ARTIFACTS=() + +for arch in amd64 arm64; do + case "$arch" in + amd64) forge_arch=x64 ;; + arm64) forge_arch=arm64 ;; + esac + + if [ -n "$INPUT" ]; then + BIN="$SIGN_ROOT/darwin-$arch/streamplace" + APP="$SIGN_ROOT/darwin-$arch/Streamplace.app" + else + BIN="$SIGN_ROOT/build-darwin-$arch/streamplace" + APP="$SIGN_ROOT/js/desktop/out/Streamplace-darwin-$forge_arch/Streamplace.app" + fi + if [ ! -x "$BIN" ]; then + echo "$BIN missing; run make build-signable first" >&2 + exit 1 + fi + if [ ! -d "$APP" ]; then + echo "$APP missing; run make build-signable first" >&2 + exit 1 + fi + + echo "=== signing darwin-$arch ===" + SIGN_NOTARY_WAIT=1 util/osxcross-codesign.sh "$BIN" + tar -C "$(dirname "$BIN")" \ + -czf "$OUT_DIR/streamplace-$VERSION-darwin-$arch.tar.gz" ./streamplace + ARTIFACTS+=("streamplace-$VERSION-darwin-$arch.tar.gz") + + hack/rcodesign-app.sh "$APP" + ( + cd "$(dirname "$APP")" + zip -qry "$OUT_DIR/streamplace-desktop-$VERSION-darwin-$arch.zip" \ + "$(basename "$APP")" + ) + ARTIFACTS+=("streamplace-desktop-$VERSION-darwin-$arch.zip") + + hack/make-dmg.sh "$APP" "Streamplace $VERSION" \ + "$OUT_DIR/streamplace-desktop-$VERSION-darwin-$arch.dmg" + ARTIFACTS+=("streamplace-desktop-$VERSION-darwin-$arch.dmg") +done + +CHECKSUMS="$OUT_DIR/streamplace-$VERSION-SHA256SUMS" +( + cd "$OUT_DIR" + "${HASH[@]}" "${ARTIFACTS[@]}" >"$(basename "$CHECKSUMS")" +) + +echo +echo "signed artifacts in $OUT_DIR:" +cat "$CHECKSUMS" diff --git a/js/desktop/forge.config.ts b/js/desktop/forge.config.ts index 76f7721c6..ba0583339 100644 --- a/js/desktop/forge.config.ts +++ b/js/desktop/forge.config.ts @@ -15,79 +15,10 @@ import { FuseV1Options, FuseVersion } from "@electron/fuses"; import { MakerAppImage } from "@reforged/maker-appimage"; import child_process from "child_process"; import fs from "fs"; -import os from "os"; import path from "path"; import { mainConfig } from "./webpack.main.config"; import { rendererConfig } from "./webpack.renderer.config"; -// Sign (and optionally notarize) a packaged macOS .app on a non-macOS host. -// Electron Forge's built-in osxSign/osxNotarize shells out to Apple's codesign -// and notarytool, so Linux uses rcodesign (apple-codesign) instead. The bundle -// is signed recursively, including the nested Electron helper apps/frameworks. -function signDarwinApp(appPath: string) { - const args = [ - "sign", - "--code-signature-flags", - "runtime", - "--entitlements-xml-path", - path.join(__dirname, "entitlements.plist"), - ]; - let p12Dir: string | undefined; - try { - if (process.env.DEVELOPER_CERTIFICATE_BASE64) { - p12Dir = fs.mkdtempSync(path.join(os.tmpdir(), "streamplace-p12-")); - const p12File = path.join(p12Dir, "certificate.p12"); - fs.writeFileSync( - p12File, - Buffer.from(process.env.DEVELOPER_CERTIFICATE_BASE64, "base64"), - ); - args.push( - "--p12-file", - p12File, - "--p12-password", - process.env.DEVELOPER_CERTIFICATE_PASSWORD ?? "", - ); - } - args.push(appPath); - console.log(`signing ${appPath} with rcodesign`); - child_process.execFileSync("rcodesign", args, { stdio: "inherit" }); - } finally { - if (p12Dir) fs.rmSync(p12Dir, { recursive: true, force: true }); - } - - if (process.env.APPLE_CODESIGN_API_KEY) { - // Apple only accepts archives, so submit a zip and then staple the ticket - // to the bundle that the makers distribute. - const notarizeDir = fs.mkdtempSync( - path.join(os.tmpdir(), "streamplace-notarize-"), - ); - try { - const zipPath = path.join(notarizeDir, "Streamplace.zip"); - child_process.execFileSync( - "zip", - ["-qry", zipPath, path.basename(appPath)], - { cwd: path.dirname(appPath), stdio: "inherit" }, - ); - child_process.execFileSync( - "rcodesign", - [ - "notary-submit", - "--api-key-file", - process.env.APPLE_CODESIGN_API_KEY, - "--wait", - zipPath, - ], - { stdio: "inherit" }, - ); - child_process.execFileSync("rcodesign", ["staple", appPath], { - stdio: "inherit", - }); - } finally { - fs.rmSync(notarizeDir, { recursive: true, force: true }); - } - } -} - export default async function () { // go get the version from the actual script let version = child_process @@ -159,7 +90,12 @@ export default async function () { // macOS uses the built-in osxSign/osxNotarize above. Elsewhere (the // builder container) sign each .app with rcodesign so fuses, signing, // and notarization all still happen before the makers run. - if (process.platform === "darwin") { + // STREAMPLACE_SKIP_SIGNING produces unsigned .app bundles for the + // split build/sign workflow (make build-signable / make sign). + if ( + process.platform === "darwin" || + process.env.STREAMPLACE_SKIP_SIGNING === "1" + ) { return; } for (const outputPath of packageResult.outputPaths) { @@ -167,7 +103,11 @@ export default async function () { .readdirSync(outputPath) .find((entry) => entry.endsWith(".app")); if (app) { - signDarwinApp(path.join(outputPath, app)); + child_process.execFileSync( + path.join(__dirname, "../../hack/rcodesign-app.sh"), + [path.join(outputPath, app)], + { stdio: "inherit" }, + ); } } }, diff --git a/util/osxcross-codesign.sh b/util/osxcross-codesign.sh index 83264df0a..1afe5d4bd 100755 --- a/util/osxcross-codesign.sh +++ b/util/osxcross-codesign.sh @@ -1,22 +1,74 @@ #!/bin/bash - +# Sign a cross-compiled macOS binary with rcodesign, and optionally notarize it. +# +# Environment: +# DEVELOPER_CERTIFICATE_BASE64, DEVELOPER_CERTIFICATE_PASSWORD +# APPLE_CODESIGN_API_KEY path to an App Store Connect API key JSON +# SIGN_NOTARIZE auto (default: notarize when the key is set), 1, 0 +# SIGN_TIMESTAMP_URL rcodesign TSA URL; "none" signs offline +# SIGN_OFFLINE 1 implies SIGN_TIMESTAMP_URL=none and SIGN_NOTARIZE=0 +# SIGN_NOTARY_WAIT 1 waits for the notarization result +# +# With no certificate this is a no-op, matching the build-only workflow. set -euo pipefail -CERTIFICATE_FILE="certificate.p12" +CERT_DIR="" +NOTARY_DIR="" +cleanup() { + if [ -n "$CERT_DIR" ]; then rm -rf "$CERT_DIR"; fi + if [ -n "$NOTARY_DIR" ]; then rm -rf "$NOTARY_DIR"; fi +} +trap cleanup EXIT + +if [ "${SIGN_OFFLINE:-0}" = "1" ]; then + SIGN_TIMESTAMP_URL="${SIGN_TIMESTAMP_URL:-none}" + SIGN_NOTARIZE="${SIGN_NOTARIZE:-0}" +fi + if [[ "${DEVELOPER_CERTIFICATE_BASE64:-}" == "" ]]; then echo "DEVELOPER_CERTIFICATE_BASE64 is not set, skipping codesign" exit 0 fi -echo "${DEVELOPER_CERTIFICATE_BASE64}" | base64 -d >"$CERTIFICATE_FILE" + +CERT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/streamplace-cert.XXXXXX")" +printf '%s' "$DEVELOPER_CERTIFICATE_BASE64" | base64 -d >"$CERT_DIR/certificate.p12" + +TIMESTAMP_ARGS=() +if [[ -n "${SIGN_TIMESTAMP_URL:-}" ]]; then + TIMESTAMP_ARGS=(--timestamp-url "$SIGN_TIMESTAMP_URL") +fi + rcodesign sign \ - --p12-file "$CERTIFICATE_FILE" \ - --p12-password "${DEVELOPER_CERTIFICATE_PASSWORD}" \ + --p12-file "$CERT_DIR/certificate.p12" \ + --p12-password "${DEVELOPER_CERTIFICATE_PASSWORD:-}" \ --code-signature-flags runtime \ + ${TIMESTAMP_ARGS[@]+"${TIMESTAMP_ARGS[@]}"} \ "$1" -rm -f "$CERTIFICATE_FILE" +rm -rf "$CERT_DIR" +CERT_DIR="" -if [[ "${APPLE_CODESIGN_API_KEY:-}" != "" ]]; then - ls -alhs "$APPLE_CODESIGN_API_KEY" - zip "$1.zip" "$1" - rcodesign notary-submit --api-key-file "$APPLE_CODESIGN_API_KEY" "$1.zip" +NOTARIZE="${SIGN_NOTARIZE:-auto}" +if [[ -z "${APPLE_CODESIGN_API_KEY:-}" ]]; then + NOTARIZE=0 +fi +if [[ "$NOTARIZE" == "1" ]]; then + if [[ "${SIGN_TIMESTAMP_URL:-http://timestamp.apple.com/ts01}" == "none" ]]; then + echo "refusing to notarize an untimestamped signature (SIGN_TIMESTAMP_URL=none)" >&2 + exit 1 + fi + NOTARY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/streamplace-notary.XXXXXX")" + ( + cd "$(dirname "$1")" + zip -q "$NOTARY_DIR/asset.zip" "$(basename "$1")" + ) + WAIT_ARGS=() + if [[ "${SIGN_NOTARY_WAIT:-0}" == "1" ]]; then + WAIT_ARGS=(--wait) + fi + rcodesign notary-submit \ + --api-key-file "$APPLE_CODESIGN_API_KEY" \ + ${WAIT_ARGS[@]+"${WAIT_ARGS[@]}"} \ + "$NOTARY_DIR/asset.zip" + rm -rf "$NOTARY_DIR" + NOTARY_DIR="" fi -- knotmirror 0.0.1 From 15c2fdbccf93fdf7db8a9070a4aed567adf33566 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 21 Oct 2026 14:17:19 -0700 Subject: [PATCH 3/9] Document build and sign stage requirements --- docs/local-ci.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/local-ci.md b/docs/local-ci.md index a7199ac92..1de2fca47 100644 --- a/docs/local-ci.md +++ b/docs/local-ci.md @@ -58,6 +58,12 @@ contains the unsigned binaries, unsigned app bundles, `VERSION`, `COMMIT`, a `SHA256SUMS` file, and a `SYMLINKS` listing. The build machine does not need any Apple credentials. +`build-signable` needs the pinned builder container (Go, Node/pnpm, Meson, +Rust, osxcross) and nothing else: no prior `make dev`, no Apple credentials, +and no macOS. `sign` is the opposite: it needs `rcodesign`, `zip`, and the +host DMG tool (`hdiutil`, or hfsprogs/libdmg-hfsplus on Linux), but not the +build toolchain. + `sign` verifies `SHA256SUMS` and `SYMLINKS`, signs each binary and app with `rcodesign`, optionally notarizes and staples, and writes the release artifacts to `bin/`: -- knotmirror 0.0.1 From 150f1adfc200e4764072a6208a8658701a9fa5ae Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 22 Oct 2026 13:56:53 -0700 Subject: [PATCH 4/9] Build darwin desktop on Linux and disable GitLab iOS job The darwin binaries are already cross-compiled and uploaded by build-darwin-*; build-desktop-darwin can package and sign the Electron app with rcodesign in the builder image instead of requiring a macOS runner. Remove util/mac-codesign.sh from ci-desktop-darwin and skip the darwin self-test on non-macOS hosts. Comment out build-ios until a macOS runner exists; keep it buildable locally with make ci-ios. --- .gitlab-ci.yml | 33 +++++++++++++++++++++++---------- Makefile | 14 ++++++++++---- docs/local-ci.md | 3 ++- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 97529707e..a4a404c04 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -140,20 +140,33 @@ build-android-release: && ${=COMMAND} # ^^ zsh-ism for running command with spaces -build-ios: - extends: - - .build-on-mac - variables: - COMMAND: make ci-ios -j16 +# The iOS build needs a macOS runner, which this project does not have yet. +# Build the archive locally on a Mac with `make ci-ios` and upload it by hand. +# build-ios: +# extends: +# - .build-on-mac +# variables: +# COMMAND: make ci-ios -j16 +# Runs on the Linux builder image. The darwin binaries arrive as package +# registry artifacts from build-darwin-*, and the Electron app is packaged and +# signed with rcodesign in the Forge postPackage hook. No macOS runner needed. build-desktop-darwin: - extends: - - .build-on-mac + stage: build + interruptible: true + image: "$DOCKER_REPO:builder-$DOCKERFILE_HASH" + timeout: 2 hours + variables: + NODE_OPTIONS: "--max_old_space_size=4096" needs: - build-darwin-amd64 - build-darwin-arm64 - variables: - COMMAND: make ci-desktop-darwin + script: + - git fetch --unshallow || echo 'already unshallow' + - make ci-desktop-darwin -j$(nproc) + artifacts: + reports: + dotenv: .ci/build.env # windows-selftest: # interruptible: true @@ -185,7 +198,7 @@ build-desktop-darwin: - build-android-debug - build-android-release - build-desktop-darwin - - build-ios + # - build-ios # no macOS runner yet; see the commented job above - test rules: - if: '$CI_COMMIT_TAG =~ /^v?\d+\.\d+\.\d+$/' diff --git a/Makefile b/Makefile index f2be3f010..484921035 100644 --- a/Makefile +++ b/Makefile @@ -569,14 +569,20 @@ sign: ci: version install app node-all-platforms ci-upload-node +# The darwin binaries are cross-compiled by build-darwin-* and downloaded here. +# The Electron app is packaged and signed on Linux by the Forge postPackage +# hook (hack/rcodesign-app.sh); no macOS keychain or util/mac-codesign.sh is +# involved. The self-test only runs when this target is invoked on macOS, since +# a darwin binary cannot execute on the Linux builder. .PHONY: ci-desktop-darwin ci-desktop-darwin: version install - ./util/mac-codesign.sh \ - && for arch in amd64 arm64; do \ + for arch in amd64 arm64; do \ curl -v --fail-with-body -O "$$CI_API_V4_URL/projects/$$CI_PROJECT_ID/packages/generic/$(BRANCH)/$(VERSION)/streamplace-$(VERSION)-darwin-$$arch.tar.gz" || exit 1 \ && tar -xzvf streamplace-$(VERSION)-darwin-$$arch.tar.gz \ - && ./streamplace --version \ - && ./streamplace self-test \ + && if [ "$$(uname -s)" = "Darwin" ]; then \ + ./streamplace --version \ + && ./streamplace self-test; \ + fi \ && mkdir -p build-darwin-$$arch \ && mv ./streamplace ./build-darwin-$$arch/streamplace; \ done \ diff --git a/docs/local-ci.md b/docs/local-ci.md index 1de2fca47..2c9d117a2 100644 --- a/docs/local-ci.md +++ b/docs/local-ci.md @@ -31,7 +31,8 @@ is `hack/all.sh`. libdmg-hfsplus). Without `DEVELOPER_CERTIFICATE_BASE64` the app is ad-hoc signed; with `APPLE_CODESIGN_API_KEY` it is notarized and stapled. - `build-ios`: `make ios` on a macOS machine. This is the only job that cannot - run on Linux, because it needs Xcode. + run on Linux, because it needs Xcode. GitLab's `build-ios` job is commented + out until a macOS runner exists; build it locally and upload by hand. The tag-only publishing jobs (`npm-release`, `release`, `deb-release`, `homebrew-release`) are not run: they need CI credentials and should not fire -- knotmirror 0.0.1 From 719619f530d968fd9b19777ce51e1f279f704976 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 22 Oct 2026 14:09:12 -0700 Subject: [PATCH 5/9] Fix local pipeline signing and Android uploads Forward signing and keystore environment into docker exec so make all on the host signs with the caller's credentials. Resolve SIGN_NOTARIZE=auto when the API key is present. Build Android bundles without the ci-upload steps, which need GitLab registry credentials. Remove stale zip entries before writing a rebuilt app. --- hack/all.sh | 7 +++++-- hack/lib/container.sh | 28 +++++++++++++++++++++++++++- hack/sign-release.sh | 3 +++ util/osxcross-codesign.sh | 12 ++++++++++-- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/hack/all.sh b/hack/all.sh index 894a66c33..a6162d1a2 100755 --- a/hack/all.sh +++ b/hack/all.sh @@ -93,11 +93,14 @@ target darwin-arm64 # --------------------------------------------------------------------------- # android jobs # --------------------------------------------------------------------------- +# `make app` above already ran `expo prebuild`, so build the bundles directly. +# The ci-android-* targets also upload to the GitLab package registry, which +# needs CI_JOB_TOKEN and fails on a local machine. step "android-debug" -run make ci-android-debug +run make android-debug step "android-release" -run make ci-android-release +run make android-release # --------------------------------------------------------------------------- # macOS desktop app (packages and signs with rcodesign on Linux) diff --git a/hack/lib/container.sh b/hack/lib/container.sh index 9aa263670..ef1f9e272 100644 --- a/hack/lib/container.sh +++ b/hack/lib/container.sh @@ -19,10 +19,36 @@ else CONTAINER="$(bash "$REPO/hack/container.sh" name)" fi +# `docker exec` starts a new process with the container's environment, not the +# host shell's. Forward credentials and signing controls explicitly so a host +# `make all` signs with the identity the caller exported instead of silently +# producing ad-hoc artifacts. Kept to an allowlist: don't leak unrelated host +# variables into the builder. +PASS_ENV=( + DEVELOPER_CERTIFICATE_BASE64 + DEVELOPER_CERTIFICATE_PASSWORD + APPLE_CODESIGN_API_KEY + SIGN_NOTARIZE + SIGN_TIMESTAMP_URL + SIGN_OFFLINE + SIGN_NOTARY_WAIT + ALLOW_ADHOC + ANDROID_KEYSTORE_BASE64 + ANDROID_KEYSTORE_PASSWORD + ANDROID_KEYSTORE_ALIAS +) + run() { if [ -z "$CONTAINER" ]; then "$@" else - docker exec -w "$REPO" "$CONTAINER" "$@" + local env_args=() + local name + for name in "${PASS_ENV[@]}"; do + if [ -n "${!name+x}" ]; then + env_args+=(-e "$name=${!name}") + fi + done + docker exec -w "$REPO" "${env_args[@]}" "$CONTAINER" "$@" fi } diff --git a/hack/sign-release.sh b/hack/sign-release.sh index 9a53fd200..fdba8b2d4 100755 --- a/hack/sign-release.sh +++ b/hack/sign-release.sh @@ -110,6 +110,9 @@ for arch in amd64 arm64; do hack/rcodesign-app.sh "$APP" ( cd "$(dirname "$APP")" + # zip updates an existing archive instead of replacing it, so remove the + # previous build's entries before signing a rebuilt app. + rm -f "$OUT_DIR/streamplace-desktop-$VERSION-darwin-$arch.zip" zip -qry "$OUT_DIR/streamplace-desktop-$VERSION-darwin-$arch.zip" \ "$(basename "$APP")" ) diff --git a/util/osxcross-codesign.sh b/util/osxcross-codesign.sh index 1afe5d4bd..3471e4c97 100755 --- a/util/osxcross-codesign.sh +++ b/util/osxcross-codesign.sh @@ -48,10 +48,18 @@ rm -rf "$CERT_DIR" CERT_DIR="" NOTARIZE="${SIGN_NOTARIZE:-auto}" -if [[ -z "${APPLE_CODESIGN_API_KEY:-}" ]]; then - NOTARIZE=0 +if [[ "$NOTARIZE" == "auto" ]]; then + if [[ -n "${APPLE_CODESIGN_API_KEY:-}" ]]; then + NOTARIZE=1 + else + NOTARIZE=0 + fi fi if [[ "$NOTARIZE" == "1" ]]; then + if [[ -z "${APPLE_CODESIGN_API_KEY:-}" ]]; then + echo "APPLE_CODESIGN_API_KEY is required for notarization" >&2 + exit 1 + fi if [[ "${SIGN_TIMESTAMP_URL:-http://timestamp.apple.com/ts01}" == "none" ]]; then echo "refusing to notarize an untimestamped signature (SIGN_TIMESTAMP_URL=none)" >&2 exit 1 -- knotmirror 0.0.1 From 122fbbd3b45f8125ea32f9c128c55ee08b2baaa9 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 22 Oct 2026 14:27:02 -0700 Subject: [PATCH 6/9] Handle offline signing and external Apple keys Stage APPLE_CODESIGN_API_KEY inside the mounted parent when it lives elsewhere, and forward the container-visible path. Make SIGN_OFFLINE=1 override existing online timestamp and notarization settings instead of only supplying defaults. --- hack/lib/container.sh | 37 +++++++++++++++++++++++++++++++++++-- hack/rcodesign-app.sh | 6 ++++-- util/osxcross-codesign.sh | 6 ++++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/hack/lib/container.sh b/hack/lib/container.sh index ef1f9e272..2953f59a4 100644 --- a/hack/lib/container.sh +++ b/hack/lib/container.sh @@ -5,6 +5,7 @@ # The caller is expected to have `set -euo pipefail` and to have changed to the # repo root; REPO may be preset. REPO="${REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +PARENT="$(dirname "$REPO")" NAME="$(basename "$REPO")" CONTAINER="" @@ -42,13 +43,45 @@ run() { if [ -z "$CONTAINER" ]; then "$@" else + # The container only mounts the checkout's parent. If the Apple key lives + # elsewhere, copy it into that mount and forward the container-visible + # path; rcodesign opens the path itself and cannot see the host's home. + local key_dir="" + local key_path="${APPLE_CODESIGN_API_KEY:-}" + if [ -n "$key_path" ]; then + case "$key_path" in + /*) ;; + *) key_path="$REPO/$key_path" ;; + esac + if [ ! -f "$key_path" ]; then + echo "APPLE_CODESIGN_API_KEY does not exist: $key_path" >&2 + return 1 + fi + if [[ "$key_path" != "$PARENT"/* ]]; then + key_dir="$(mktemp -d "$PARENT/.streamplace-key.XXXXXX")" + cp "$key_path" "$key_dir/key.json" + chmod 700 "$key_dir" + chmod 600 "$key_dir/key.json" + key_path="$key_dir/key.json" + fi + fi + local env_args=() local name for name in "${PASS_ENV[@]}"; do if [ -n "${!name+x}" ]; then - env_args+=(-e "$name=${!name}") + local value="${!name}" + if [ "$name" = "APPLE_CODESIGN_API_KEY" ]; then + value="$key_path" + fi + env_args+=(-e "$name=$value") fi done - docker exec -w "$REPO" "${env_args[@]}" "$CONTAINER" "$@" + + if [ -n "$key_dir" ]; then + ( trap 'rm -rf "$key_dir"' EXIT; docker exec -w "$REPO" "${env_args[@]}" "$CONTAINER" "$@" ) + else + docker exec -w "$REPO" "${env_args[@]}" "$CONTAINER" "$@" + fi fi } diff --git a/hack/rcodesign-app.sh b/hack/rcodesign-app.sh index ec6fb1a1e..d0fc1136d 100755 --- a/hack/rcodesign-app.sh +++ b/hack/rcodesign-app.sh @@ -29,8 +29,10 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" ENTITLEMENTS="${ENTITLEMENTS:-$ROOT/js/desktop/entitlements.plist}" if [ "${SIGN_OFFLINE:-0}" = "1" ]; then - SIGN_TIMESTAMP_URL="${SIGN_TIMESTAMP_URL:-none}" - SIGN_NOTARIZE="${SIGN_NOTARIZE:-0}" + # Offline means no timestamp server and no notarization. Override any online + # settings exported for a previous run instead of only supplying defaults. + SIGN_TIMESTAMP_URL=none + SIGN_NOTARIZE=0 fi TIMESTAMP_ARGS=() diff --git a/util/osxcross-codesign.sh b/util/osxcross-codesign.sh index 3471e4c97..f3b7c2196 100755 --- a/util/osxcross-codesign.sh +++ b/util/osxcross-codesign.sh @@ -21,8 +21,10 @@ cleanup() { trap cleanup EXIT if [ "${SIGN_OFFLINE:-0}" = "1" ]; then - SIGN_TIMESTAMP_URL="${SIGN_TIMESTAMP_URL:-none}" - SIGN_NOTARIZE="${SIGN_NOTARIZE:-0}" + # Offline means no timestamp server and no notarization. Override any online + # settings exported for a previous run instead of only supplying defaults. + SIGN_TIMESTAMP_URL=none + SIGN_NOTARIZE=0 fi if [[ "${DEVELOPER_CERTIFICATE_BASE64:-}" == "" ]]; then -- knotmirror 0.0.1 From 4b2af7361447bf39a84709adf14cff7632be4430 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Mon, 26 Oct 2026 14:33:12 -0700 Subject: [PATCH 7/9] Gate Electron built-in signing to macOS On Linux the NOTARIZATION_* variables are present in CI, but @electron/packager's osxNotarize path invokes Apple's notarytool and fails. Only configure packager's built-in signing/notarization on macOS; Linux signs in the postPackage hook with rcodesign. --- js/desktop/forge.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/desktop/forge.config.ts b/js/desktop/forge.config.ts index ba0583339..b54d4ef63 100644 --- a/js/desktop/forge.config.ts +++ b/js/desktop/forge.config.ts @@ -183,7 +183,11 @@ export default async function () { }), ], }; + // Only the macOS host uses @electron/packager's built-in signing and + // notarization. Elsewhere the postPackage hook signs with rcodesign; setting + // osxNotarize here would make packager invoke Apple's notarytool on Linux. if ( + process.platform === "darwin" && process.env.NOTARIZATION_TEAM_ID && process.env.NOTARIZATION_EMAIL && process.env.NOTARIZATION_PASSWORD -- knotmirror 0.0.1 From 965fc1934be79a1d83ad60a56f8c42eea985814b Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Mon, 26 Oct 2026 16:20:41 -0700 Subject: [PATCH 8/9] Scope hardened runtime to nested app binaries rcodesign applies --code-signature-flags only to the main target unless it is scoped with main:. Apple rejected the app because the Electron helpers and framework helpers lacked the hardened runtime. Apply the runtime flag and entitlements to the main target and all nested Mach-Os. --- hack/rcodesign-app.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/hack/rcodesign-app.sh b/hack/rcodesign-app.sh index d0fc1136d..2c36db5cb 100755 --- a/hack/rcodesign-app.sh +++ b/hack/rcodesign-app.sh @@ -56,9 +56,13 @@ if [ -n "${DEVELOPER_CERTIFICATE_BASE64:-}" ]; then fi echo "signing $APP with rcodesign" +# `main:` applies the setting to the bundle's main executable *and* every +# nested Mach-O. Without it the helpers get signatures without the hardened +# runtime flag, and Apple rejects the archive during notarization with +# "The executable does not have the hardened runtime enabled." rcodesign sign \ - --code-signature-flags runtime \ - --entitlements-xml-path "$ENTITLEMENTS" \ + --code-signature-flags main:runtime \ + --entitlements-xml-path "main:$ENTITLEMENTS" \ ${TIMESTAMP_ARGS[@]+"${TIMESTAMP_ARGS[@]}"} \ ${CERT_ARGS[@]+"${CERT_ARGS[@]}"} \ "$APP" -- knotmirror 0.0.1 From 75cfc57b78491cb3b2261b9c63b78c52eca3ce47 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Mon, 26 Oct 2026 16:56:03 -0700 Subject: [PATCH 9/9] Use for-notarization to harden nested app binaries main: is parsed as a path scope, not the special main scope, so the helpers kept CodeSignatureFlags(0x0) and Apple rejected the archive. rcodesign's --for-notarization flag enables the hardened runtime on every Mach-O in the bundle; use it when a Developer ID certificate and timestamp server are available, with the single-target flag as the ad-hoc/offline fallback. --- hack/rcodesign-app.sh | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/hack/rcodesign-app.sh b/hack/rcodesign-app.sh index 2c36db5cb..a26109819 100755 --- a/hack/rcodesign-app.sh +++ b/hack/rcodesign-app.sh @@ -56,13 +56,22 @@ if [ -n "${DEVELOPER_CERTIFICATE_BASE64:-}" ]; then fi echo "signing $APP with rcodesign" -# `main:` applies the setting to the bundle's main executable *and* every -# nested Mach-O. Without it the helpers get signatures without the hardened -# runtime flag, and Apple rejects the archive during notarization with -# "The executable does not have the hardened runtime enabled." +# --for-notarization enables the hardened runtime flag on every Mach-O in the +# bundle, including nested helpers; plain --code-signature-flags only applies +# to the main target. It requires a Developer ID certificate and a timestamp +# server, so fall back to the single-target flag for ad-hoc/offline signing. +SIGN_ARGS=() +if [ -n "${APPLE_CODESIGN_API_KEY:-}" ] && + [ "${SIGN_NOTARIZE:-auto}" != "0" ] && + [ "${SIGN_TIMESTAMP_URL:-}" != "none" ]; then + SIGN_ARGS+=(--for-notarization) +else + SIGN_ARGS+=(--code-signature-flags runtime) +fi + rcodesign sign \ - --code-signature-flags main:runtime \ - --entitlements-xml-path "main:$ENTITLEMENTS" \ + "${SIGN_ARGS[@]}" \ + --entitlements-xml-path "$ENTITLEMENTS" \ ${TIMESTAMP_ARGS[@]+"${TIMESTAMP_ARGS[@]}"} \ ${CERT_ARGS[@]+"${CERT_ARGS[@]}"} \ "$APP" -- knotmirror 0.0.1