From 728456848913c3e47cf988cee57a3d5b27af693e Mon Sep 17 00:00:00 2001 From: kua-agent Date: Wed, 2 Sep 2026 09:02:45 +0400 Subject: [PATCH] =?UTF-8?q?release=20tooling:=20publish-update.sh=20?= =?UTF-8?q?=E2=80=94=20bump,=20build,=20sign,=20zip,=20appcast,=20upload,?= =?UTF-8?q?=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/publish-update.sh | 287 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100755 scripts/publish-update.sh diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh new file mode 100755 index 0000000..ca6550d --- /dev/null +++ b/scripts/publish-update.sh @@ -0,0 +1,287 @@ +#!/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" -- 2.54.0