From 9884c844d4619a73b3204cbd1f38108a8ec5db60 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 10:00:41 +0400 Subject: [PATCH] =?UTF-8?q?release:=20publish-update.sh=20=E2=80=94=20reso?= =?UTF-8?q?lve=20a=20real=20signing=20identity,=20notarize,=20record=20it?= =?UTF-8?q?=20in=20the=20appcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SIGN_IDENTITY now comes from REDLINE_SIGN_IDENTITY, else the first "Developer ID Application" identity in the keychain (REDLINE_KEYCHAIN adds --keychain everywhere it's searched/used), else the existing Apple Development identity with a loud WARNING that Gatekeeper will block first install elsewhere. build-app.sh still has to sign first with its own hardcoded Apple Development identity (that pair is what keeps the Screen Recording grant alive) — this script now re-signs the resulting bundle with the resolved identity, --options runtime --timestamp, before zipping. Notarization is optional: set REDLINE_NOTARY_PROFILE (a notarytool keychain profile) or all three of REDLINE_NOTARY_KEY_ID/_ISSUER/_KEY_PATH, and after the zip is built the script submits it, waits, and on Accepted staples Redline.app, rebuilds the zip and DMG from the stapled app (a new build_dmg() that ditto-copies whatever is already at .build/Redline.app rather than re-invoking make-dmg.sh, which would rebuild from source and strip both the resolved signature and the staple), staples the DMG, and requires `spctl -a -vv -t exec` to say "accepted" or the script aborts. Absent notary config: prints NOT NOTARIZED and continues exactly as before. appcast.json gains "notarized" and "teamIdentifier" (from codesign -dv); confirmed UpdateChecker's Appcast Decodable already ignores unknown JSON keys (verified with a standalone decode), so no app-side change was needed for old appcasts to keep working. Ends with a summary block: identity used, notarized yes/no, spctl verdict, team, sha256. --test dry-run behaviour (upload to .../test/, skip the git commit) is unchanged. Known gap, out of scope here: build-app.sh's own pre-sign step still hard- requires its hardcoded Apple Development identity in the keychain even when a Developer ID identity is what will actually ship — untouched per the task boundary (this file only). Co-Authored-By: Claude Fable 5.1 --- scripts/publish-update.sh | 233 +++++++++++++++++++++++++++++++++----- 1 file changed, 204 insertions(+), 29 deletions(-) diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh index ca6550d..6a636d5 100755 --- a/scripts/publish-update.sh +++ b/scripts/publish-update.sh @@ -13,7 +13,10 @@ PLISTBUDDY="/usr/libexec/PlistBuddy" REMOTE_HOST="mmd01" REMOTE_BASE="/opt/mmd-installer-content/cowork/redline" PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline" -SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" +BUNDLE_ID="ai.flowmaster.shotdeck" +# Used both as the fallback signing identity and as what build-app.sh itself +# still hardcodes for its own (pre-final) signing pass. +FALLBACK_SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" usage() { echo "Usage: $0 [\"notes\"]" >&2 @@ -72,6 +75,7 @@ else PUBLIC_DIR="${PUBLIC_BASE}" fi +APP_BUNDLE="${ROOT}/.build/Redline.app" ZIP_NAME="Redline-${VERSION}.zip" DMG_NAME="Redline-${VERSION}.dmg" ZIP_PATH="${ROOT}/.build/${ZIP_NAME}" @@ -139,6 +143,38 @@ else echo "==> --test: skipping git commit of version bump" fi +# --- Resolve the signing identity for the shipped artifacts ----------------- +# REDLINE_KEYCHAIN (optional): a specific keychain to search/sign against, +# for hosts where the Developer ID identity does not live in the login +# keychain that codesign searches by default. +FIND_IDENTITY_ARGS=(-v -p codesigning) +CODESIGN_KEYCHAIN_ARGS=() +if [[ -n "${REDLINE_KEYCHAIN:-}" ]]; then + FIND_IDENTITY_ARGS+=("${REDLINE_KEYCHAIN}") + CODESIGN_KEYCHAIN_ARGS=(--keychain "${REDLINE_KEYCHAIN}") +fi + +if [[ -n "${REDLINE_SIGN_IDENTITY:-}" ]]; then + SIGN_IDENTITY="${REDLINE_SIGN_IDENTITY}" + echo "==> Signing identity: ${SIGN_IDENTITY} (REDLINE_SIGN_IDENTITY)" +else + DEVELOPER_ID_LINE="$(security find-identity "${FIND_IDENTITY_ARGS[@]}" 2>/dev/null \ + | grep -o '"Developer ID Application:[^"]*"' | head -n1 || true)" + DEVELOPER_ID="${DEVELOPER_ID_LINE//\"/}" + if [[ -n "${DEVELOPER_ID}" ]]; then + SIGN_IDENTITY="${DEVELOPER_ID}" + echo "==> Signing identity: ${SIGN_IDENTITY} (auto-detected Developer ID Application)" + else + SIGN_IDENTITY="${FALLBACK_SIGN_IDENTITY}" + echo + echo "************************************************************************" + echo "WARNING: signing with Apple Development identity — not Developer ID;" + echo "Gatekeeper will block first install on other Macs." + echo "************************************************************************" + echo + fi +fi + # Restricted HOMEs (agent sandboxes) hide the login keychain from codesign. # Re-run signed steps with the account's real home when the identity is missing. signing_home() { @@ -167,31 +203,85 @@ run_signed() { echo "==> Building signed Redline.app" run_signed ./scripts/build-app.sh -if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then - echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2 +if [[ ! -d "${APP_BUNDLE}" ]]; then + echo "Signed app missing at ${APP_BUNDLE}" >&2 exit 1 fi +# build-app.sh always signs with its own hardcoded Apple Development identity +# first (it has to — that identifier+identity pair is what keeps the Screen +# Recording grant alive). Re-sign here with the identity actually resolved +# above, which is what ships. A no-op when the two happen to be the same. +echo "==> Signing ${APP_BUNDLE} with resolved identity" +run_signed codesign --force --options runtime --timestamp \ + "${CODESIGN_KEYCHAIN_ARGS[@]}" \ + --sign "${SIGN_IDENTITY}" \ + --identifier "${BUNDLE_ID}" \ + "${APP_BUNDLE}" + +build_zip() { + mkdir -p "${ROOT}/.build" + ( + cd "${ROOT}/.build" + rm -f "${ZIP_NAME}" + ditto -c -k --keepParent Redline.app "${ZIP_NAME}" + ) + if [[ ! -s "${ZIP_PATH}" ]]; then + echo "Zip was not created at ${ZIP_PATH}" >&2 + exit 1 + fi +} + +# Rebuilds the manual-installer DMG from whatever is currently at +# ${APP_BUNDLE} — never re-invokes build-app.sh, so a prior custom signature +# or notarization staple on ${APP_BUNDLE} survives into the DMG untouched. +build_dmg() { + local staging="${ROOT}/.build/dmg-staging" + local mount_point="${ROOT}/.build/dmg-mnt" + + rm -rf "${staging}" + mkdir -p "${staging}" + ditto "${APP_BUNDLE}" "${staging}/Redline.app" + ln -s /Applications "${staging}/Applications" + + mkdir -p "$(dirname "${DMG_PATH}")" + rm -f "${DMG_PATH}" + hdiutil create -volname "Redline" -srcfolder "${staging}" -ov -format UDZO "${DMG_PATH}" + + if [[ -d "${mount_point}" ]] && /sbin/mount | grep -F -q "${mount_point}"; then + hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force + fi + rm -rf "${mount_point}" + mkdir -p "${mount_point}" + + hdiutil attach "${DMG_PATH}" -nobrowse -readonly -mountpoint "${mount_point}" + + local ok=1 + if [[ ! -d "${mount_point}/Redline.app" ]]; then + echo "Verification failed: Redline.app missing from mounted DMG" >&2 + ok=0 + fi + if [[ "${ok}" -eq 1 && "$(readlink "${mount_point}/Applications" 2>/dev/null || true)" != "/Applications" ]]; then + echo "Verification failed: Applications does not point at /Applications" >&2 + ok=0 + fi + if [[ "${ok}" -eq 1 ]] && ! codesign --verify --deep --verbose=2 "${mount_point}/Redline.app"; then + ok=0 + fi + + hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force || true + + if [[ "${ok}" -ne 1 ]]; then + exit 1 + fi + if [[ ! -s "${DMG_PATH}" ]]; then + echo "DMG was not created at ${DMG_PATH}" >&2 + exit 1 + fi +} + echo "==> Zipping Redline.app -> ${ZIP_PATH}" -mkdir -p "${ROOT}/.build" -( - cd "${ROOT}/.build" - rm -f "${ZIP_NAME}" - ditto -c -k --keepParent Redline.app "${ZIP_NAME}" -) - -if [[ ! -s "${ZIP_PATH}" ]]; then - echo "Zip was not created at ${ZIP_PATH}" >&2 - exit 1 -fi - -echo "==> Building manual installer DMG" -run_signed ./scripts/make-dmg.sh - -if [[ ! -s "${DMG_PATH}" ]]; then - echo "DMG was not created at ${DMG_PATH}" >&2 - exit 1 -fi +build_zip SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")" @@ -200,18 +290,95 @@ PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" echo "==> Zip SHA256: ${SHA256}" echo " Zip bytes: ${ZIP_BYTES}" +# --- Notarization (optional) ------------------------------------------------- +# Either REDLINE_NOTARY_PROFILE (a `notarytool store-credentials` keychain +# profile) or all three of REDLINE_NOTARY_KEY_ID / REDLINE_NOTARY_ISSUER / +# REDLINE_NOTARY_KEY_PATH (App Store Connect API key). Absent both: skip. +NOTARIZED=0 +NOTARY_CONFIGURED=0 +if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then + NOTARY_CONFIGURED=1 +elif [[ -n "${REDLINE_NOTARY_KEY_ID:-}" && -n "${REDLINE_NOTARY_ISSUER:-}" && -n "${REDLINE_NOTARY_KEY_PATH:-}" ]]; then + NOTARY_CONFIGURED=1 +fi + +if [[ "${NOTARY_CONFIGURED}" -eq 1 ]]; then + echo "==> Submitting ${ZIP_PATH} to notarytool" + NOTARY_ARGS=(xcrun notarytool submit "${ZIP_PATH}" --wait --timeout 30m) + if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then + NOTARY_ARGS+=(--keychain-profile "${REDLINE_NOTARY_PROFILE}") + else + NOTARY_ARGS+=( + --key "${REDLINE_NOTARY_KEY_PATH}" + --key-id "${REDLINE_NOTARY_KEY_ID}" + --issuer "${REDLINE_NOTARY_ISSUER}" + ) + fi + + set +e + NOTARY_OUTPUT="$("${NOTARY_ARGS[@]}" 2>&1)" + NOTARY_STATUS=$? + set -e + echo "${NOTARY_OUTPUT}" + if [[ "${NOTARY_STATUS}" -ne 0 ]]; then + echo "notarytool submit failed (exit ${NOTARY_STATUS})." >&2 + exit 1 + fi + if ! grep -qi 'status: *Accepted' <<<"${NOTARY_OUTPUT}"; then + echo "notarytool did not report Accepted." >&2 + exit 1 + fi + NOTARIZED=1 + + echo "==> Stapling ${APP_BUNDLE}" + xcrun stapler staple "${APP_BUNDLE}" + + echo "==> Rebuilding zip from the stapled app" + build_zip + SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" + ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")" + echo " Zip SHA256: ${SHA256}" + echo " Zip bytes: ${ZIP_BYTES}" + + echo "==> Rebuilding DMG from the stapled app" + build_dmg + + echo "==> Stapling ${DMG_PATH}" + xcrun stapler staple "${DMG_PATH}" +else + echo "==> REDLINE_NOTARY_KEY_ID/ISSUER/KEY_PATH (or REDLINE_NOTARY_PROFILE) not set" + echo "NOT NOTARIZED" + echo "==> Building manual installer DMG" + build_dmg +fi + +echo "==> Gatekeeper check: spctl -a -vv -t exec ${APP_BUNDLE}" +set +e +SPCTL_OUTPUT="$(spctl -a -vv -t exec "${APP_BUNDLE}" 2>&1)" +SPCTL_STATUS=$? +set -e +echo "${SPCTL_OUTPUT}" +if [[ "${SPCTL_STATUS}" -ne 0 ]] || ! grep -qi 'accepted' <<<"${SPCTL_OUTPUT}"; then + echo "spctl did not report accepted for ${APP_BUNDLE}." >&2 + exit 1 +fi + +TEAM_IDENTIFIER="$(codesign -dv "${APP_BUNDLE}" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2}')" + echo "==> Writing ${APPCAST_PATH}" -python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY' +python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" "${NOTARIZED}" "${TEAM_IDENTIFIER}" <<'PY' import json import sys -version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:] +version, zip_url, sha256, notes, pub_date, out_path, notarized, team_identifier = sys.argv[1:] payload = { "version": version, "zipURL": zip_url, "sha256": sha256, "notes": notes, "pubDate": pub_date, + "notarized": notarized == "1", + "teamIdentifier": team_identifier, } with open(out_path, "w", encoding="utf-8") as fh: json.dump(payload, fh, indent=2) @@ -280,8 +447,16 @@ fi echo echo "Published v${VERSION}" -echo " appcast: ${APPCAST_URL}" -echo " zip: ${ZIP_URL}" -echo " sha256: ${SHA256}" -echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}" -echo " dmg: ${PUBLIC_DIR}/Redline.dmg" +echo " identity: ${SIGN_IDENTITY}" +if [[ "${NOTARIZED}" -eq 1 ]]; then + echo " notarized: yes" +else + echo " notarized: no" +fi +echo " spctl: ${SPCTL_OUTPUT}" +echo " team: ${TEAM_IDENTIFIER}" +echo " appcast: ${APPCAST_URL}" +echo " zip: ${ZIP_URL}" +echo " sha256: ${SHA256}" +echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}" +echo " dmg: ${PUBLIC_DIR}/Redline.dmg"