scripts/release.sh
#!/usr/bin/env bash
set -euo pipefail

# One-button release script for Dayflow.
# - Bumps version (minor by default; --major to bump major)
#   - Updates Xcode target MARKETING_VERSION and CURRENT_PROJECT_VERSION
#   - Keeps Info.plist values in sync
# - Builds, signs, notarizes DMG
# - Signs update (Sparkle) using Keychain-stored PEM
# - Creates GitHub Release and uploads DMG (draft)
# - Prepends new item to docs/appcast.xml and pushes
# - Publishes the release (undrafts)
#
# Requirements:
# - Xcode + Developer ID cert (login keychain)
# - Sparkle CLI (sign_update) in PATH
# - gh CLI authenticated (gh auth status)
# - Notarization creds (optional): NOTARY_PROFILE or Apple ID/ASC envs
# - Sparkle private key present in your login Keychain (generated by `generate_keys`).
#   By default, sign_update uses account `ed25519`. For CI, you can export the
#   base64 secret and set SPARKLE_PRIVATE_KEY to sign without Keychain.

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)

PLIST=${PLIST:-"$REPO_ROOT/Dayflow/Dayflow/Info.plist"}
APP_NAME=${APP_NAME:-Dayflow}
SCHEME=${SCHEME:-Dayflow}
CONFIG=${CONFIG:-Release}
# Optional: override Keychain account name for sign_update; defaults to "ed25519"
SIGN_ACCOUNT=${SIGN_ACCOUNT:-}
MSV=${MSV:-13.0}
DMG_NAME=${DMG_NAME:-"${APP_NAME}.dmg"}

MODE=bump_minor
DRY_RUN=0
NO_NOTARIZE=0
NOTES_FILE=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --major) MODE=bump_major; shift ;;
    --minor) MODE=bump_minor; shift ;;
    --patch) MODE=bump_patch; shift ;;
    --notes) NOTES_FILE="$2"; shift 2 ;;
    --dry-run) DRY_RUN=1; shift ;;
    --no-notarize) NO_NOTARIZE=1; shift ;;
    *) echo "Unknown arg: $1" >&2; exit 1 ;;
  esac
done

err() { echo "ERROR: $*" >&2; exit 1; }

require() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; }

require "/usr/libexec/PlistBuddy"
require git
require gh
if [[ ${DRY_RUN:-0} -eq 0 ]]; then
  require sign_update
fi

if [[ ! -f "$PLIST" ]]; then err "Info.plist not found at $PLIST"; fi

PBP="$REPO_ROOT/Dayflow/Dayflow.xcodeproj/project.pbxproj"

get_plist() { /usr/libexec/PlistBuddy -c "Print :$1" "$PLIST" 2>/dev/null || true; }
set_plist() {
  local key="$1"; shift
  local val="$*"
  if ! /usr/libexec/PlistBuddy -c "Set :${key} ${val}" "$PLIST" >/dev/null 2>&1; then
    /usr/libexec/PlistBuddy -c "Add :${key} string ${val}" "$PLIST" >/dev/null
  fi
}

## Determine current versions
# Prefer Xcode project (MARKETING_VERSION/CURRENT_PROJECT_VERSION),
# which is what the built app uses when GENERATE_INFOPLIST_FILE is YES.
if [[ -f "$PBP" ]]; then
  CURRENT_SHORT=$(grep -m1 -E "MARKETING_VERSION = " "$PBP" | sed -E 's/.*MARKETING_VERSION = ([^;]+);/\1/')
  CURRENT_BUILD=$(grep -m1 -E "CURRENT_PROJECT_VERSION = " "$PBP" | sed -E 's/.*CURRENT_PROJECT_VERSION = ([^;]+);/\1/')
fi
# Fall back to Info.plist if not present in project
if [[ -z "${CURRENT_SHORT:-}" ]]; then
  CURRENT_SHORT=$(get_plist CFBundleShortVersionString)
fi
if [[ -z "${CURRENT_BUILD:-}" ]]; then
  CURRENT_BUILD=$(get_plist CFBundleVersion)
fi
[[ -n "$CURRENT_SHORT" ]] || err "Unable to determine marketing version (CFBundleShortVersionString or MARKETING_VERSION)"
if [[ -z "$CURRENT_BUILD" ]]; then CURRENT_BUILD=0; fi

# Ensure build number monotonically increases across releases by considering
# the highest sparkle:version present in the existing appcast.
APPCAST_PATH="$REPO_ROOT/docs/appcast.xml"
if [[ -f "$APPCAST_PATH" ]]; then
  APPCAST_MAX_BUILD=$(grep -Eo 'sparkle:version="[0-9]+"' "$APPCAST_PATH" | sed -E 's/[^0-9]//g' | sort -n | tail -1 || true)
  if [[ -n "$APPCAST_MAX_BUILD" ]]; then
    if [[ "$APPCAST_MAX_BUILD" -gt "$CURRENT_BUILD" ]]; then
      echo "Note: Raising build from $CURRENT_BUILD to appcast max $APPCAST_MAX_BUILD to keep it monotonic."
      CURRENT_BUILD=$APPCAST_MAX_BUILD
    fi
  fi
fi

IFS=. read -r MA MI PA <<<"${CURRENT_SHORT}"
MA=${MA:-0}; MI=${MI:-0}; PA=${PA:-0}

case "$MODE" in
  bump_major) NEW_SHORT="$((MA+1)).0.0" ;;
  bump_patch) NEW_SHORT="$MA.$MI.$((PA+1))" ;;
  *)          NEW_SHORT="$MA.$((MI+1)).0" ;;
esac

NEW_BUILD=$((CURRENT_BUILD + 1))

echo "Current version: $CURRENT_SHORT ($CURRENT_BUILD)"
echo "New version:     $NEW_SHORT ($NEW_BUILD)"

if [[ $DRY_RUN -eq 1 ]]; then
  echo "Dry run. No changes made."; exit 0
fi

echo "[1/8] Bumping versions in project and Info.plist…"
# First, update the Xcode project so UI reflects the correct version.
if [[ -f "$PBP" ]]; then
  if xcrun -f agvtool >/dev/null 2>&1; then
    ( cd "$REPO_ROOT/Dayflow" && \
      xcrun agvtool new-marketing-version "$NEW_SHORT" >/dev/null || true; \
      xcrun agvtool new-version -all "$NEW_BUILD" >/dev/null || true )
  fi
  # Ensure values are updated even if agvtool didn't touch them
  perl -pi -e "s/MARKETING_VERSION = [^;]+;/MARKETING_VERSION = $NEW_SHORT;/g" "$PBP"
  perl -pi -e "s/CURRENT_PROJECT_VERSION = [^;]+;/CURRENT_PROJECT_VERSION = $NEW_BUILD;/g" "$PBP"
fi

# Keep Info.plist in sync regardless of GENERATE_INFOPLIST_FILE
set_plist CFBundleShortVersionString "$NEW_SHORT"
set_plist CFBundleVersion "$NEW_BUILD"

git add "$PLIST" "$PBP" 2>/dev/null || true
git commit -m "release: bump to v$NEW_SHORT ($NEW_BUILD)" || true

TAG="v$NEW_SHORT"

echo "[2/8] Building, signing, and creating DMG…"
if [[ $NO_NOTARIZE -eq 1 ]]; then
  (cd "$REPO_ROOT" && NO_NOTARIZE=1 ./scripts/release_dmg.sh)
else
  (cd "$REPO_ROOT" && ./scripts/release_dmg.sh)
fi

[[ -f "$REPO_ROOT/$DMG_NAME" ]] || err "DMG not found: $REPO_ROOT/$DMG_NAME"

echo "[3/8] Signing update with Sparkle…"
if [[ -n "${SPARKLE_PRIVATE_KEY:-}" ]]; then
  ED_SIG=$(printf "%s\n" "$SPARKLE_PRIVATE_KEY" | sign_update --ed-key-file - -p "$REPO_ROOT/$DMG_NAME")
elif [[ -n "$SIGN_ACCOUNT" ]]; then
  ED_SIG=$(sign_update --account "$SIGN_ACCOUNT" -p "$REPO_ROOT/$DMG_NAME")
else
  ED_SIG=$(sign_update -p "$REPO_ROOT/$DMG_NAME")
fi
[[ -n "$ED_SIG" ]] || err "Could not obtain edSignature from sign_update"

OWNER_REPO=$(git -C "$REPO_ROOT" remote get-url origin | sed -E 's#.*github.com[:/]([^/]+/[^.]+)(\.git)?#\1#')
[[ -n "$OWNER_REPO" ]] || err "Unable to detect owner/repo from git remote"

TITLE="$APP_NAME $NEW_SHORT"
REL_ARGS=("$TAG" "$REPO_ROOT/$DMG_NAME" --title "$TITLE" --draft)
if [[ -n "$NOTES_FILE" ]]; then REL_ARGS+=(--notes-file "$NOTES_FILE"); fi

if gh release view "$TAG" >/dev/null 2>&1; then
  echo "[4/8] Release $TAG already exists; uploading asset (clobber)…"
  gh release upload "$TAG" "$REPO_ROOT/$DMG_NAME" --clobber >/dev/null
else
  echo "[4/8] Creating draft GitHub release $TAG and uploading asset…"
  gh release create "${REL_ARGS[@]}"
fi

echo "[5/8] Publishing release (undraft)…"
gh release edit "$TAG" --title "$TITLE" --draft=false >/dev/null

echo "[6/8] Resolving canonical asset + release URLs…"
# After publishing, the canonical browser_download_url contains /download/vX.Y.Z/
ASSET_URL=""
for i in {1..10}; do
  ASSET_URL=$(gh release view "$TAG" --json assets --jq \
    ".assets[] | select(.name==\"$DMG_NAME\").url" 2>/dev/null || true)
  [[ -n "$ASSET_URL" ]] && break
  sleep 2
done
RELEASE_URL=$(gh release view "$TAG" --json url --jq .url)

[[ -n "$ASSET_URL" ]] || err "Failed to obtain canonical browser_download_url for $DMG_NAME"

echo "[7/8] Updating appcast (docs/appcast.xml)…"
"$SCRIPT_DIR/update_appcast.sh" \
  --dmg "$REPO_ROOT/$DMG_NAME" \
  --url "$ASSET_URL" \
  --short "$NEW_SHORT" \
  --build "$NEW_BUILD" \
  --signature "$ED_SIG" \
  --msv "$MSV" \
  --notes "$RELEASE_URL" \
  --out "$REPO_ROOT/docs/appcast.xml"

git add "$REPO_ROOT/docs/appcast.xml" "$REPO_ROOT/docs/.nojekyll" 2>/dev/null || true
git commit -m "release: update appcast for v$NEW_SHORT" || true
git tag -f "$TAG" || true
git push origin HEAD
git push origin "$TAG" --force

echo "[8/8] Post-release sanity check…"
echo "Feed URL should be reachable at: https://dayflow.so/appcast.xml"

echo "[8/8] Done. Version $NEW_SHORT ($NEW_BUILD) released."
