#!/usr/bin/env bash set -euo pipefail # One-command Redline release: bump Info.plist, commit, signed build, zip, # DMG, appcast, upload to mmd01, verify the public URLs. # Hidden flag: --test — upload under .../redline/test/ and skip the git commit. ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT}" PLIST="${ROOT}/Info.plist" PLISTBUDDY="/usr/libexec/PlistBuddy" REMOTE_HOST="mmd01" REMOTE_BASE="/opt/mmd-installer-content/cowork/redline" PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline" 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 exit 1 } TEST_MODE=0 VERSION="" NOTES="" NOTES_SET=0 for arg in "$@"; do case "${arg}" in --test) TEST_MODE=1 ;; --help|-h) usage ;; --*) echo "Unknown argument: ${arg}" >&2 usage ;; *) if [[ -z "${VERSION}" ]]; then VERSION="${arg}" elif [[ "${NOTES_SET}" -eq 0 ]]; then NOTES="${arg}" NOTES_SET=1 else echo "Unexpected extra argument: ${arg}" >&2 usage fi ;; esac done if [[ -z "${VERSION}" ]]; then usage fi if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)*$ ]]; then echo "Version '${VERSION}' is not a semver (e.g. 1.2.3 or 0.0.0-test)." >&2 exit 1 fi if [[ "${VERSION}" == *'/'* || "${VERSION}" == *'..'* ]]; then echo "Version contains illegal path characters: ${VERSION}" >&2 exit 1 fi if [[ "${TEST_MODE}" -eq 1 ]]; then REMOTE_DIR="${REMOTE_BASE}/test" PUBLIC_DIR="${PUBLIC_BASE}/test" else REMOTE_DIR="${REMOTE_BASE}" 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}" DMG_PATH="${ROOT}/.build/Redline.dmg" APPCAST_PATH="${ROOT}/.build/appcast.json" ZIP_URL="${PUBLIC_DIR}/${ZIP_NAME}" APPCAST_URL="${PUBLIC_DIR}/appcast.json" if [[ "${TEST_MODE}" -eq 1 ]]; then echo "==> Publish Redline ${VERSION} (test)" else echo "==> Publish Redline ${VERSION}" fi echo " remote: ${REMOTE_HOST}:${REMOTE_DIR}/" echo " public: ${PUBLIC_DIR}/" if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "Not inside a git work tree." >&2 exit 1 fi # Tracked files must match HEAD. Untracked files are ignored so this script # can be dry-run (--test) before it is itself committed. if [[ -n "$(git status --porcelain -uno)" ]]; then echo "git tree is not clean; commit or stash before publishing." >&2 git status --porcelain -uno >&2 exit 1 fi if [[ ! -x "${PLISTBUDDY}" ]]; then echo "PlistBuddy not found at ${PLISTBUDDY}" >&2 exit 1 fi if [[ ! -f "${PLIST}" ]]; then echo "Info.plist not found at ${PLIST}" >&2 exit 1 fi restore_plist() { git checkout -- "${PLIST}" >/dev/null 2>&1 || true } if [[ "${TEST_MODE}" -eq 1 ]]; then trap restore_plist EXIT fi CURRENT_BUILD="$("${PLISTBUDDY}" -c 'Print :CFBundleVersion' "${PLIST}")" if [[ ! "${CURRENT_BUILD}" =~ ^[0-9]+$ ]]; then echo "CFBundleVersion is not an integer: ${CURRENT_BUILD}" >&2 exit 1 fi NEW_BUILD=$((CURRENT_BUILD + 1)) echo "==> Bumping Info.plist" echo " CFBundleShortVersionString -> ${VERSION}" echo " CFBundleVersion ${CURRENT_BUILD} -> ${NEW_BUILD}" "${PLISTBUDDY}" -c "Set :CFBundleShortVersionString ${VERSION}" "${PLIST}" "${PLISTBUDDY}" -c "Set :CFBundleVersion ${NEW_BUILD}" "${PLIST}" if [[ "${TEST_MODE}" -eq 0 ]]; then echo "==> Committing version bump on $(git rev-parse --abbrev-ref HEAD)" git add "${PLIST}" git commit -m "release: v${VERSION}" 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() { if security find-identity -v -p codesigning 2>/dev/null | grep -Fq "${SIGN_IDENTITY}"; then echo "${HOME}" return fi local rh rh="$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" if [[ -n "${rh}" && -d "${rh}" ]]; then echo "${rh}" else echo "${HOME}" fi } run_signed() { local sign_home sign_home="$(signing_home)" if [[ "${sign_home}" != "${HOME}" ]]; then echo "==> Using HOME=${sign_home} so codesign can see the login keychain" fi HOME="${sign_home}" "$@" } echo "==> Building signed Redline.app" run_signed ./scripts/build-app.sh 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[@]+"${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}" build_zip SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")" 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 if [[ "${NOTARIZED}" -eq 1 ]]; then echo "spctl did not report accepted for ${APP_BUNDLE} although it was notarized." >&2 exit 1 fi echo "WARNING: Gatekeeper does not accept this build (not notarized). First install on other Macs needs right-click > Open." >&2 fi TEAM_IDENTIFIER="$(codesign -dv "${APP_BUNDLE}" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2}')" if [[ -z "${TEAM_IDENTIFIER}" ]]; then echo "No TeamIdentifier on ${APP_BUNDLE} — the build is not signed with a team identity; refusing to publish." >&2 exit 1 fi echo "==> Writing ${APPCAST_PATH}" 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, 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) fh.write("\n") PY echo "==> Uploading to ${REMOTE_HOST}:${REMOTE_DIR}/" ssh -o BatchMode=yes "${REMOTE_HOST}" "mkdir -p '${REMOTE_DIR}'" rsync -e "ssh -o BatchMode=yes" -av "${ZIP_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${ZIP_NAME}" rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/Redline.dmg" rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${DMG_NAME}" rsync -e "ssh -o BatchMode=yes" -av "${APPCAST_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/appcast.json" ssh -o BatchMode=yes "${REMOTE_HOST}" \ "chmod 644 \ '${REMOTE_DIR}/${ZIP_NAME}' \ '${REMOTE_DIR}/Redline.dmg' \ '${REMOTE_DIR}/${DMG_NAME}' \ '${REMOTE_DIR}/appcast.json'" echo "==> Verifying public appcast ${APPCAST_URL}" APPCAST_BODY="" ok=0 attempt=1 while [[ "${attempt}" -le 15 ]]; do if APPCAST_BODY="$(curl -fsS "${APPCAST_URL}")"; then echo "${APPCAST_BODY}" if grep -F -q "${VERSION}" <<<"${APPCAST_BODY}"; then echo "OK: appcast contains ${VERSION}" ok=1 break fi echo "appcast fetched but does not contain '${VERSION}' (attempt ${attempt})" >&2 else echo "appcast fetch failed (attempt ${attempt})" >&2 fi attempt=$((attempt + 1)) sleep 2 done if [[ "${ok}" -ne 1 ]]; then echo "Public appcast verification failed for ${APPCAST_URL}" >&2 exit 1 fi echo "==> Verifying public zip HEAD ${ZIP_URL}" ok=0 attempt=1 HEAD_OUT="" while [[ "${attempt}" -le 15 ]]; do HEAD_OUT="$(curl -sS -D - -o /dev/null -I "${ZIP_URL}" || true)" echo "${HEAD_OUT}" HTTP_CODE="$(awk 'BEGIN{c=""} toupper($1) ~ /^HTTP\//{c=$2} END{print c}' <<<"${HEAD_OUT}" | tr -d '\r')" CONTENT_LENGTH="$(awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' <<<"${HEAD_OUT}" | tail -n 1)" if [[ "${HTTP_CODE}" == "200" && "${CONTENT_LENGTH}" == "${ZIP_BYTES}" ]]; then echo "OK: zip HTTP ${HTTP_CODE}, Content-Length ${CONTENT_LENGTH} matches local ${ZIP_BYTES}" ok=1 break fi echo "zip HEAD mismatch (attempt ${attempt}): HTTP '${HTTP_CODE}', Content-Length '${CONTENT_LENGTH}', local '${ZIP_BYTES}'" >&2 attempt=$((attempt + 1)) sleep 2 done if [[ "${ok}" -ne 1 ]]; then echo "Public zip verification failed for ${ZIP_URL}" >&2 exit 1 fi echo 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 " zip: ${ZIP_URL}" echo " sha256: ${SHA256}" echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}" echo " dmg: ${PUBLIC_DIR}/Redline.dmg"