release: publish-update.sh — resolve a real signing identity, notarize, record it in the appcast

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 10:00:41 +04:00
co-authored by Claude Fable 5.1
parent 365220ade8
commit 9884c844d4
+191 -16
View File
@@ -13,7 +13,10 @@ PLISTBUDDY="/usr/libexec/PlistBuddy"
REMOTE_HOST="mmd01" REMOTE_HOST="mmd01"
REMOTE_BASE="/opt/mmd-installer-content/cowork/redline" REMOTE_BASE="/opt/mmd-installer-content/cowork/redline"
PUBLIC_BASE="https://get.baobab-ts.com/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() { usage() {
echo "Usage: $0 <version> [\"notes\"]" >&2 echo "Usage: $0 <version> [\"notes\"]" >&2
@@ -72,6 +75,7 @@ else
PUBLIC_DIR="${PUBLIC_BASE}" PUBLIC_DIR="${PUBLIC_BASE}"
fi fi
APP_BUNDLE="${ROOT}/.build/Redline.app"
ZIP_NAME="Redline-${VERSION}.zip" ZIP_NAME="Redline-${VERSION}.zip"
DMG_NAME="Redline-${VERSION}.dmg" DMG_NAME="Redline-${VERSION}.dmg"
ZIP_PATH="${ROOT}/.build/${ZIP_NAME}" ZIP_PATH="${ROOT}/.build/${ZIP_NAME}"
@@ -139,6 +143,38 @@ else
echo "==> --test: skipping git commit of version bump" echo "==> --test: skipping git commit of version bump"
fi 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. # 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. # Re-run signed steps with the account's real home when the identity is missing.
signing_home() { signing_home() {
@@ -167,31 +203,85 @@ run_signed() {
echo "==> Building signed Redline.app" echo "==> Building signed Redline.app"
run_signed ./scripts/build-app.sh run_signed ./scripts/build-app.sh
if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then if [[ ! -d "${APP_BUNDLE}" ]]; then
echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2 echo "Signed app missing at ${APP_BUNDLE}" >&2
exit 1 exit 1
fi fi
echo "==> Zipping Redline.app -> ${ZIP_PATH}" # build-app.sh always signs with its own hardcoded Apple Development identity
mkdir -p "${ROOT}/.build" # 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" cd "${ROOT}/.build"
rm -f "${ZIP_NAME}" rm -f "${ZIP_NAME}"
ditto -c -k --keepParent Redline.app "${ZIP_NAME}" ditto -c -k --keepParent Redline.app "${ZIP_NAME}"
) )
if [[ ! -s "${ZIP_PATH}" ]]; then
if [[ ! -s "${ZIP_PATH}" ]]; then
echo "Zip was not created at ${ZIP_PATH}" >&2 echo "Zip was not created at ${ZIP_PATH}" >&2
exit 1 exit 1
fi fi
}
echo "==> Building manual installer DMG" # Rebuilds the manual-installer DMG from whatever is currently at
run_signed ./scripts/make-dmg.sh # ${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"
if [[ ! -s "${DMG_PATH}" ]]; then 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 echo "DMG was not created at ${DMG_PATH}" >&2
exit 1 exit 1
fi fi
}
echo "==> Zipping Redline.app -> ${ZIP_PATH}"
build_zip
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")" 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 SHA256: ${SHA256}"
echo " Zip bytes: ${ZIP_BYTES}" 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}" 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 json
import sys 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 = { payload = {
"version": version, "version": version,
"zipURL": zip_url, "zipURL": zip_url,
"sha256": sha256, "sha256": sha256,
"notes": notes, "notes": notes,
"pubDate": pub_date, "pubDate": pub_date,
"notarized": notarized == "1",
"teamIdentifier": team_identifier,
} }
with open(out_path, "w", encoding="utf-8") as fh: with open(out_path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2) json.dump(payload, fh, indent=2)
@@ -280,6 +447,14 @@ fi
echo echo
echo "Published v${VERSION}" echo "Published v${VERSION}"
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 " appcast: ${APPCAST_URL}"
echo " zip: ${ZIP_URL}" echo " zip: ${ZIP_URL}"
echo " sha256: ${SHA256}" echo " sha256: ${SHA256}"