#!/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" 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 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 # 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 "${ROOT}/.build/Redline.app" ]]; then echo "Signed app missing at ${ROOT}/.build/Redline.app" >&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 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}" echo "==> Writing ${APPCAST_PATH}" python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY' import json import sys version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:] payload = { "version": version, "zipURL": zip_url, "sha256": sha256, "notes": notes, "pubDate": pub_date, } 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 " appcast: ${APPCAST_URL}" echo " zip: ${ZIP_URL}" echo " sha256: ${SHA256}" echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}" echo " dmg: ${PUBLIC_DIR}/Redline.dmg"