#!/usr/bin/env bashset -euo pipefailSCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"ENV_FILE="$ROOT_DIR/.env"RELEASE_REPO="joelbqz/writer-computer"NOTES_FILE=""while [ $# -gt 0 ]; do case "$1" in --notes-file) NOTES_FILE="$2" shift 2 ;; --notes-file=*) NOTES_FILE="${1#*=}" shift ;; *) echo "Error: unknown argument: $1" echo "Usage: $0 --notes-file <path>" exit 1 ;; esacdoneif [ -z "$NOTES_FILE" ]; then echo "Error: --notes-file <path> is required" echo "Pass a markdown file with the user-facing release notes (drafted by the agent from CHANGELOG.md)." exit 1fiif [ ! -s "$NOTES_FILE" ]; then echo "Error: notes file is missing or empty: $NOTES_FILE" exit 1fi# Load environment variablesif [ ! -f "$ENV_FILE" ]; then echo "Error: .env file not found at $ENV_FILE" echo "" echo "Create it with:" echo " APPLE_SIGNING_IDENTITY=\"Developer ID Application: Your Name (TEAMID)\"" echo " APPLE_ID=\"your@apple.id\"" echo " APPLE_PASSWORD=\"xxxx-xxxx-xxxx-xxxx\" # app-specific password" echo " APPLE_TEAM_ID=\"XXXXXXXXXX\"" echo " TAURI_SIGNING_PRIVATE_KEY=\"/absolute/path/to/writer-updater-key\"" echo " TAURI_SIGNING_PRIVATE_KEY_PASSWORD=\"\" # empty if keypair has no password" exit 1fiset -asource "$ENV_FILE"set +afor var in APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY; do if [ -z "${!var:-}" ]; then echo "Error: $var is not set in .env" exit 1 fidone# `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` is optional but tauri-cli checks the env# var is present — export an empty default so the build doesn't fail on macOS.export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}"# Read version from tauri.conf.jsonTAURI_CONF="$ROOT_DIR/apps/desktop/src-tauri/tauri.conf.json"VERSION=$(python3 -c "import json; print(json.load(open('$TAURI_CONF'))['version'])")TAG="v$VERSION"# Pre-flight: must be on master, clean, in sync with origin, and the tag must# not already exist anywhere. These checks are cheap — fail before the long# release build rather than after.CURRENT_BRANCH=$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)if [ "$CURRENT_BRANCH" != "master" ]; then echo "Error: releases must be cut from master, currently on '$CURRENT_BRANCH'" exit 1fiif ! git -C "$ROOT_DIR" diff-index --quiet HEAD --; then echo "Error: working tree has uncommitted changes — commit the version bump first" git -C "$ROOT_DIR" status --short exit 1fiecho "Fetching origin to verify sync..."git -C "$ROOT_DIR" fetch origin master --tagsLOCAL_REV=$(git -C "$ROOT_DIR" rev-parse HEAD)REMOTE_REV=$(git -C "$ROOT_DIR" rev-parse origin/master)BASE_REV=$(git -C "$ROOT_DIR" merge-base HEAD origin/master)if [ "$LOCAL_REV" != "$REMOTE_REV" ] && [ "$BASE_REV" != "$REMOTE_REV" ]; then echo "Error: local master is not a fast-forward of origin/master" echo " local: $LOCAL_REV" echo " origin: $REMOTE_REV" echo " Pull or rebase before releasing." exit 1fiif git -C "$ROOT_DIR" rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then echo "Error: tag $TAG already exists locally — bump the version or delete the tag" exit 1fiif git -C "$ROOT_DIR" ls-remote --tags --exit-code origin "refs/tags/$TAG" >/dev/null 2>&1; then echo "Error: tag $TAG already exists on origin — bump the version" exit 1fi# Push master so the commit the release points at is on origin before we build.# Idempotent if local already matches origin.echo "Pushing master to origin..."git -C "$ROOT_DIR" push origin masterecho "Building Writer $TAG..."# Build signed and notarized DMG + updater artifacts (.app.tar.gz + .sig).cd "$ROOT_DIR/apps/desktop"vp exec tauri build --bundles app,dmgBUNDLE_DIR="$ROOT_DIR/apps/desktop/src-tauri/target/release/bundle"DMG_DIR="$BUNDLE_DIR/dmg"MACOS_DIR="$BUNDLE_DIR/macos"DMG_FILE=$(ls "$DMG_DIR"/*.dmg 2>/dev/null | head -1 || true)TAR_FILE=$(ls "$MACOS_DIR"/*.app.tar.gz 2>/dev/null | head -1 || true)SIG_FILE=$(ls "$MACOS_DIR"/*.app.tar.gz.sig 2>/dev/null | head -1 || true)if [ -z "$DMG_FILE" ]; then echo "Error: No DMG found in $DMG_DIR" exit 1fiif [ -z "$TAR_FILE" ] || [ -z "$SIG_FILE" ]; then echo "Error: Updater artifacts missing in $MACOS_DIR" echo " Expected: *.app.tar.gz and *.app.tar.gz.sig" echo " Check that \`createUpdaterArtifacts\` is true and TAURI_SIGNING_PRIVATE_KEY is valid." exit 1fiecho ""echo "Built: $(basename "$DMG_FILE") ($(du -h "$DMG_FILE" | cut -f1))"echo "Built: $(basename "$TAR_FILE") ($(du -h "$TAR_FILE" | cut -f1))"# Determine target triple for latest.json (arm64 host → aarch64).HOST_ARCH=$(uname -m)case "$HOST_ARCH" in arm64|aarch64) TARGET="darwin-aarch64" ;; x86_64) TARGET="darwin-x86_64" ;; *) echo "Error: unsupported host architecture $HOST_ARCH"; exit 1 ;;esacSIGNATURE=$(cat "$SIG_FILE")TAR_NAME=$(basename "$TAR_FILE")PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")NOTES="Writer $TAG"DOWNLOAD_URL="https://github.com/$RELEASE_REPO/releases/download/$TAG/$TAR_NAME"LATEST_JSON="$BUNDLE_DIR/latest.json"python3 - "$LATEST_JSON" "$VERSION" "$NOTES" "$PUB_DATE" "$TARGET" "$SIGNATURE" "$DOWNLOAD_URL" <<'PY'import json, sysout_path, version, notes, pub_date, target, signature, url = sys.argv[1:]payload = { "version": version, "notes": notes, "pub_date": pub_date, "platforms": { target: { "signature": signature, "url": url, } },}with open(out_path, "w") as f: json.dump(payload, f, indent=2)PYecho "Built: latest.json ($TARGET)"# Create a DRAFT GitHub Release with DMG, updater tarball, signed manifest,# and the agent-drafted user-facing notes.echo ""echo "Creating draft release $TAG on $RELEASE_REPO..."gh release create "$TAG" "$DMG_FILE" "$TAR_FILE" "$LATEST_JSON" \ --repo "$RELEASE_REPO" \ --title "Writer $TAG" \ --notes-file "$NOTES_FILE" \ --draftDRAFT_URL=$(gh release view "$TAG" --repo "$RELEASE_REPO" --json url --jq '.url')# Tag this repo so the release artifact is pinned to a specific commit. If you# end up abandoning the draft, delete the tag manually.echo ""echo "Tagging $TAG locally and pushing to origin..."git -C "$ROOT_DIR" tag "$TAG"git -C "$ROOT_DIR" push origin "$TAG"echo ""echo "Draft created: $DRAFT_URL"echo "Review the notes and click Publish to ship it."
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="$ROOT_DIR/.env"
RELEASE_REPO="joelbqz/writer-computer"
NOTES_FILE=""
while [ $# -gt 0 ]; do
case "$1" in
--notes-file)
NOTES_FILE="$2"
shift 2
;;
--notes-file=*)
NOTES_FILE="${1#*=}"
shift
;;
*)
echo "Error: unknown argument: $1"
echo "Usage: $0 --notes-file <path>"
exit 1
;;
esac
done
if [ -z "$NOTES_FILE" ]; then
echo "Error: --notes-file <path> is required"
echo "Pass a markdown file with the user-facing release notes (drafted by the agent from CHANGELOG.md)."
exit 1
fi
if [ ! -s "$NOTES_FILE" ]; then
echo "Error: notes file is missing or empty: $NOTES_FILE"
exit 1
fi
# Load environment variables
if [ ! -f "$ENV_FILE" ]; then
echo "Error: .env file not found at $ENV_FILE"
echo ""
echo "Create it with:"
echo " APPLE_SIGNING_IDENTITY=\"Developer ID Application: Your Name (TEAMID)\""
echo " APPLE_ID=\"your@apple.id\""
echo " APPLE_PASSWORD=\"xxxx-xxxx-xxxx-xxxx\" # app-specific password"
echo " APPLE_TEAM_ID=\"XXXXXXXXXX\""
echo " TAURI_SIGNING_PRIVATE_KEY=\"/absolute/path/to/writer-updater-key\""
echo " TAURI_SIGNING_PRIVATE_KEY_PASSWORD=\"\" # empty if keypair has no password"
exit 1
fi
set -a
source "$ENV_FILE"
set +a
for var in APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY; do
if [ -z "${!var:-}" ]; then
echo "Error: $var is not set in .env"
exit 1
fi
done
# `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` is optional but tauri-cli checks the env
# var is present — export an empty default so the build doesn't fail on macOS.
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}"
# Read version from tauri.conf.json
TAURI_CONF="$ROOT_DIR/apps/desktop/src-tauri/tauri.conf.json"
VERSION=$(python3 -c "import json; print(json.load(open('$TAURI_CONF'))['version'])")
TAG="v$VERSION"
# Pre-flight: must be on master, clean, in sync with origin, and the tag must
# not already exist anywhere. These checks are cheap — fail before the long
# release build rather than after.
CURRENT_BRANCH=$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" != "master" ]; then
echo "Error: releases must be cut from master, currently on '$CURRENT_BRANCH'"
exit 1
fi
if ! git -C "$ROOT_DIR" diff-index --quiet HEAD --; then
echo "Error: working tree has uncommitted changes — commit the version bump first"
git -C "$ROOT_DIR" status --short
exit 1
fi
echo "Fetching origin to verify sync..."
git -C "$ROOT_DIR" fetch origin master --tags
LOCAL_REV=$(git -C "$ROOT_DIR" rev-parse HEAD)
REMOTE_REV=$(git -C "$ROOT_DIR" rev-parse origin/master)
BASE_REV=$(git -C "$ROOT_DIR" merge-base HEAD origin/master)
if [ "$LOCAL_REV" != "$REMOTE_REV" ] && [ "$BASE_REV" != "$REMOTE_REV" ]; then
echo "Error: local master is not a fast-forward of origin/master"
echo " local: $LOCAL_REV"
echo " origin: $REMOTE_REV"
echo " Pull or rebase before releasing."
exit 1
fi
if git -C "$ROOT_DIR" rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then
echo "Error: tag $TAG already exists locally — bump the version or delete the tag"
exit 1
fi
if git -C "$ROOT_DIR" ls-remote --tags --exit-code origin "refs/tags/$TAG" >/dev/null 2>&1; then
echo "Error: tag $TAG already exists on origin — bump the version"
exit 1
fi
# Push master so the commit the release points at is on origin before we build.
# Idempotent if local already matches origin.
echo "Pushing master to origin..."
git -C "$ROOT_DIR" push origin master
echo "Building Writer $TAG..."
# Build signed and notarized DMG + updater artifacts (.app.tar.gz + .sig).
cd "$ROOT_DIR/apps/desktop"
vp exec tauri build --bundles app,dmg
BUNDLE_DIR="$ROOT_DIR/apps/desktop/src-tauri/target/release/bundle"
DMG_DIR="$BUNDLE_DIR/dmg"
MACOS_DIR="$BUNDLE_DIR/macos"
DMG_FILE=$(ls "$DMG_DIR"/*.dmg 2>/dev/null | head -1 || true)
TAR_FILE=$(ls "$MACOS_DIR"/*.app.tar.gz 2>/dev/null | head -1 || true)
SIG_FILE=$(ls "$MACOS_DIR"/*.app.tar.gz.sig 2>/dev/null | head -1 || true)
if [ -z "$DMG_FILE" ]; then
echo "Error: No DMG found in $DMG_DIR"
exit 1
fi
if [ -z "$TAR_FILE" ] || [ -z "$SIG_FILE" ]; then
echo "Error: Updater artifacts missing in $MACOS_DIR"
echo " Expected: *.app.tar.gz and *.app.tar.gz.sig"
echo " Check that \`createUpdaterArtifacts\` is true and TAURI_SIGNING_PRIVATE_KEY is valid."
exit 1
fi
echo ""
echo "Built: $(basename "$DMG_FILE") ($(du -h "$DMG_FILE" | cut -f1))"
echo "Built: $(basename "$TAR_FILE") ($(du -h "$TAR_FILE" | cut -f1))"
# Determine target triple for latest.json (arm64 host → aarch64).
HOST_ARCH=$(uname -m)
case "$HOST_ARCH" in
arm64|aarch64) TARGET="darwin-aarch64" ;;
x86_64) TARGET="darwin-x86_64" ;;
*) echo "Error: unsupported host architecture $HOST_ARCH"; exit 1 ;;
esac
SIGNATURE=$(cat "$SIG_FILE")
TAR_NAME=$(basename "$TAR_FILE")
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
NOTES="Writer $TAG"
DOWNLOAD_URL="https://github.com/$RELEASE_REPO/releases/download/$TAG/$TAR_NAME"
LATEST_JSON="$BUNDLE_DIR/latest.json"
python3 - "$LATEST_JSON" "$VERSION" "$NOTES" "$PUB_DATE" "$TARGET" "$SIGNATURE" "$DOWNLOAD_URL" <<'PY'
import json, sys
out_path, version, notes, pub_date, target, signature, url = sys.argv[1:]
payload = {
"version": version,
"notes": notes,
"pub_date": pub_date,
"platforms": {
target: {
"signature": signature,
"url": url,
}
},
}
with open(out_path, "w") as f:
json.dump(payload, f, indent=2)
PY
echo "Built: latest.json ($TARGET)"
# Create a DRAFT GitHub Release with DMG, updater tarball, signed manifest,
# and the agent-drafted user-facing notes.
echo ""
echo "Creating draft release $TAG on $RELEASE_REPO..."
gh release create "$TAG" "$DMG_FILE" "$TAR_FILE" "$LATEST_JSON" \
--repo "$RELEASE_REPO" \
--title "Writer $TAG" \
--notes-file "$NOTES_FILE" \
--draft
DRAFT_URL=$(gh release view "$TAG" --repo "$RELEASE_REPO" --json url --jq '.url')
# Tag this repo so the release artifact is pinned to a specific commit. If you
# end up abandoning the draft, delete the tag manually.
echo ""
echo "Tagging $TAG locally and pushing to origin..."
git -C "$ROOT_DIR" tag "$TAG"
git -C "$ROOT_DIR" push origin "$TAG"
echo ""
echo "Draft created: $DRAFT_URL"
echo "Review the notes and click Publish to ship it."