deploy.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT_DIR"
usage() {
cat <<'EOF'
Usage: npm run deploy [-- auto|all|custom|windows]
Minddy's single publishing assistant.
auto recommend scopes based on modified files
all publish the core, deploy the web app, and publish the Linux and Windows desktop applications
custom ask a question for each scope
windows publish only the existing version's Microsoft Store packages
With no argument, an interactive menu offers the automatic, all, and custom modes.
Scopes:
public core SemVer version + tag + GitHub Release + artifacts
Cloud web www.minddy.app instance deployed from `production`
desktop Linux and Microsoft Store Windows packages attached to the core release;
macOS is signed and published from the maintainer's Mac
windows x64 and ARM64 Store MSIX packages, without the Linux job
Marketing is part of the web build: a marketing-only change suggests
a Cloud deployment without artificially creating a core version.
A core release publishes the version already prepared on main: prepare it in
a pull request with `npm run release:prepare -- X.Y.Z`. This assistant never
pushes to main.
When a required pentest is still pending, an interactive private-repository
test release can be explicitly accepted. For non-interactive use, set
MINDDY_PRIVATE_TEST_RELEASE=1 together with documented residual risks.
EOF
}
if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
usage
exit 0
fi
MODE="${1:-}"
if [ "$#" -gt 1 ] || { [ -n "$MODE" ] && [ "$MODE" != "auto" ] && [ "$MODE" != "all" ] && [ "$MODE" != "custom" ] && [ "$MODE" != "windows" ]; }; then
usage
exit 1
fi
CURRENT=$(git branch --show-current)
if [ -z "$CURRENT" ]; then
echo "Error: detached HEAD. Checkout main first."
exit 1
fi
if [ "$CURRENT" != "main" ]; then
echo "Error: run deploy from main (current: $CURRENT)."
exit 1
fi
if [ -n "$(git status --porcelain)" ]; then
echo "Error: the working tree must be clean before a release or deploy."
git status --short
exit 1
fi
echo "→ Refreshing main, production and release tags..."
git fetch origin main production --tags
if ! git show-ref --verify --quiet refs/remotes/origin/production; then
echo "Error: origin/production does not exist."
exit 1
fi
git merge --ff-only origin/main
# `main` only accepts squash-merged pull requests, so this assistant never
# pushes to it: the candidate is the commit the remote already has.
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
echo "Error: local main has commits that origin/main lacks. Publish them through a pull request first."
exit 1
fi
SCOPE=$(node scripts/release-scope.mjs)
json_value() {
node -e 'const value=JSON.parse(process.argv[1]); console.log(value[process.argv[2]])' "$SCOPE" "$1"
}
json_count() {
node -e 'const value=JSON.parse(process.argv[1]); console.log(value[process.argv[2]].length)' "$SCOPE" "$1"
}
AUTO_CORE=$(json_value core)
AUTO_WEB=$(json_value web)
AUTO_DESKTOP=$(json_value desktop)
MARKETING=$(json_value marketing)
CORE_COUNT=$(json_count coreFiles)
WEB_COUNT=$(json_count webFiles)
MARKETING_COUNT=$(json_count marketingFiles)
echo ""
echo "Automatic detection:"
echo "public core: $AUTO_CORE ($CORE_COUNT files since the last tag)"
echo " Cloud web : $AUTO_WEB ($WEB_COUNT files since production)"
echo "desktop: $AUTO_DESKTOP (shell fingerprint)"
if [ "$MARKETING" = "true" ]; then
echo "marketing: $MARKETING_COUNT files — included in web deployment"
fi
if [ -z "$MODE" ]; then
if [ ! -t 0 ]; then
MODE="auto"
else
echo ""
echo "What would you like to do?"
echo " 1) Automatic recommendation"
echo " 2) Publish everything directly"
echo "3) Choose perimeter by perimeter"
echo "4) Cancel"
read -r -p "Choice [1-4]: " MENU_CHOICE
case "$MENU_CHOICE" in
1) MODE="auto" ;;
2) MODE="all" ;;
3) MODE="custom" ;;
4) echo "Canceled."; exit 0 ;;
*) echo "Invalid choice."; exit 1 ;;
esac
fi
fi
CORE=0
WEB=0
DESKTOP=0
yes_no() {
local prompt="$1"
local default="$2"
local suffix="[y/N]"
[ "$default" = "yes" ] && suffix="[Y/n]"
read -r -p "$prompt $suffix : " answer
if [ -z "$answer" ]; then
[ "$default" = "yes" ]
else
case "$answer" in y|Y|yes|YES|o|O|oui|OUI) return 0 ;; *) return 1 ;; esac
fi
}
case "$MODE" in
auto|all|windows) ;;
custom)
if yes_no "Publish a new version of the public heart?" "$([ "$AUTO_CORE" = "true" ] && echo yes || echo no)"; then CORE=1; fi
if yes_no "Deploy the Minddy Cloud web (marketing included)?" "$([ "$AUTO_WEB" = "true" ] && echo yes || echo no)"; then WEB=1; fi
if yes_no "Build and publish the Linux and Windows desktop applications?" "$([ "$AUTO_DESKTOP" = "true" ] && echo yes || echo no)"; then DESKTOP=1; fi
;;
esac
SELECTION=$(node -e '
import("./scripts/release-scope.mjs").then(({ selectReleaseScopes }) => {
const detected = JSON.parse(process.argv[1]);
const custom = { core: process.argv[3] === "1", web: process.argv[4] === "1", desktop: process.argv[5] === "1" };
process.stdout.write(JSON.stringify(selectReleaseScopes(process.argv[2], detected, custom)));
});
' "$SCOPE" "$MODE" "$CORE" "$WEB" "$DESKTOP")
CORE=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).core ? "1" : "0")' "$SELECTION")
WEB=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).web ? "1" : "0")' "$SELECTION")
DESKTOP=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).desktop ? "1" : "0")' "$SELECTION")
DESKTOP_TARGET="all"
[ "$MODE" = "windows" ] && DESKTOP_TARGET="windows"
if [ "$CORE" -eq 0 ] && [ "$WEB" -eq 0 ] && [ "$DESKTOP" -eq 0 ]; then
echo "Nothing to publish based on this choice."
exit 0
fi
echo ""
echo "Publication selected: core=$CORE · web=$WEB · desktop=$DESKTOP · desktop target=$DESKTOP_TARGET"
SECURITY_CHECKLIST_VERSION="2.0"
SECURITY_REVIEW_REF="${MINDDY_SECURITY_REVIEW_REF:-}"
RESIDUAL_RISKS="${MINDDY_RESIDUAL_RISKS:-}"
PENTEST_STATUS="${MINDDY_PENTEST_STATUS:-}"
PRIVATE_TEST_RELEASE="${MINDDY_PRIVATE_TEST_RELEASE:-0}"
if [ "$PRIVATE_TEST_RELEASE" != "0" ] && [ "$PRIVATE_TEST_RELEASE" != "1" ]; then
echo "Error: MINDDY_PRIVATE_TEST_RELEASE must be 0 or 1."
exit 1
fi
require_gh() {
if ! command -v gh >/dev/null 2>&1; then
echo "Error: GitHub CLI (gh) is required for public core or desktop releases."
exit 1
fi
if ! gh auth status >/dev/null 2>&1; then
echo "Error: gh is not authenticated. Run: gh auth login"
exit 1
fi
}
wait_for_run() {
local workflow="$1"
local sha="$2"
local event="${3:-}"
local branch="${4:-}"
local run_id=""
local attempt
for attempt in $(seq 1 60); do
local args=(run list --workflow "$workflow" --commit "$sha" --limit 1 --json databaseId --jq '.[0].databaseId // empty')
[ -n "$event" ] && args+=(--event "$event")
[ -n "$branch" ] && args+=(--branch "$branch")
run_id=$(gh "${args[@]}")
[ -n "$run_id" ] && break
sleep 5
done
if [ -z "$run_id" ]; then
echo "Error: workflow $workflow did not start for $sha."
exit 1
fi
gh run watch "$run_id" --exit-status
}
dispatch_and_wait() {
local workflow="$1"
local ref="$2"
local sha="$3"
shift 3
local previous_run
local run_id=""
local attempt
previous_run=$(gh run list --workflow "$workflow" --commit "$sha" --event workflow_dispatch --branch "$ref" --limit 1 --json databaseId --jq '.[0].databaseId // empty')
gh workflow run "$workflow" --ref "$ref" "$@"
for attempt in $(seq 1 60); do
run_id=$(gh run list --workflow "$workflow" --commit "$sha" --event workflow_dispatch --branch "$ref" --limit 1 --json databaseId --jq '.[0].databaseId // empty')
[ -n "$run_id" ] && [ "$run_id" != "$previous_run" ] && break
sleep 5
done
if [ -z "$run_id" ] || [ "$run_id" = "$previous_run" ]; then
echo "Error: the new $workflow run did not start for $sha."
exit 1
fi
gh run watch "$run_id" --exit-status
}
TARGET_VERSION=""
if [ "$CORE" -eq 1 ]; then
require_gh
if [ "$CURRENT" != "main" ]; then
echo "Error: a public core release must be prepared from main (current: $CURRENT)."
exit 1
fi
CURRENT_VERSION=$(node -p "require('./package.json').version")
if [ -n "$(git tag --list "v$CURRENT_VERSION")" ]; then
echo ""
echo "Version $CURRENT_VERSION is already published, and main only accepts pull requests."
if [ "$MODE" = "auto" ]; then
# Product files change with most deployments; a Cloud deployment must
# not wait for a version nobody has prepared.
echo "No new version is prepared on main: deploying the Cloud web without a core release."
CORE=0
# Selecting the core had implied the web promotion its tag needs.
[ "$AUTO_WEB" = "true" ] || WEB=0
if [ "$WEB" -eq 0 ] && [ "$DESKTOP" -eq 0 ]; then
echo "Nothing to publish: production already serves main."
exit 0
fi
else
echo "Prepare the next version on a work branch, then run this command again once it is merged:"
echo " npm run work:start -- \"release X.Y.Z\""
echo " npm run release:prepare -- X.Y.Z"
echo " add the X.Y.Z row to deploy/self-hosted/compatibility.json and bump deploy/self-hosted/.env.example"
echo " git commit -s -am \"chore: prepare release X.Y.Z\""
echo " npm run work:pr -- \"chore: prepare release X.Y.Z\" -m \"...\""
echo "The pull request runs the Release preflight workflow on the candidate."
exit 1
fi
fi
fi
if [ "$CORE" -eq 1 ]; then
TARGET_VERSION="$CURRENT_VERSION"
echo "→ Public core v$TARGET_VERSION is prepared on main and not published yet."
node -e 'import("./scripts/release-lib.mjs").then(({assertVersion}) => assertVersion(process.argv[1]))' "$TARGET_VERSION"
fi
if [ "$DESKTOP" -eq 1 ]; then
require_gh
fi
# Quick check of the local mechanism that prepares the request. The gates of
# trust runs in CI on the pushed SHA; this post is not the source.
echo "→ Checking release tooling..."
npm run test:release
# Settings and release data that a workflow would otherwise find missing after
# the protected approval: the Actions allowlist, the environments' secrets and
# variables, and the prepared version's compatibility data.
echo "→ Checking repository settings for the selected workflows..."
PREFLIGHT_ARGS=()
if [ "$CORE" -eq 1 ] || [ "$WEB" -eq 1 ]; then PREFLIGHT_ARGS+=(--workflow promote-production.yml); fi
if [ "$CORE" -eq 1 ]; then PREFLIGHT_ARGS+=(--workflow release.yml --version "$TARGET_VERSION"); fi
if [ "$DESKTOP" -eq 1 ]; then PREFLIGHT_ARGS+=(--workflow desktop-release.yml); fi
node scripts/release-preflight.mjs "${PREFLIGHT_ARGS[@]}"
# The report must name the exact SHA that will be promoted, so the review is
# requested once the candidate is known and before any workflow starts.
if [ "$WEB" -eq 1 ]; then
SECURITY_CANDIDATE_SHA=$(git rev-parse HEAD)
echo ""
echo "Mandatory security review for $SECURITY_CANDIDATE_SHA: docs/security-release-checklist.md (v$SECURITY_CHECKLIST_VERSION)"
if [ -t 0 ]; then
if [ -z "$SECURITY_REVIEW_REF" ]; then
read -r -p "Stable reference of the report (URL or issue, without spaces):" SECURITY_REVIEW_REF
fi
if [ -z "$RESIDUAL_RISKS" ]; then
if yes_no "Are any residual risks recorded in this report?" no; then
RESIDUAL_RISKS="documented"
else
RESIDUAL_RISKS="none"
fi
fi
if [ -z "$PENTEST_STATUS" ]; then
echo "Pentest decision:"
echo "1) not required after the recorded risk-based review"
echo "2) required, completed and blocking findings retested"
echo "3) required but not completed (promotion will be refused)"
read -r -p "Choice [1-3]: " PENTEST_CHOICE
case "$PENTEST_CHOICE" in
1) PENTEST_STATUS="not-required" ;;
2) PENTEST_STATUS="completed" ;;
3) PENTEST_STATUS="required-not-completed" ;;
*) echo "Invalid choice."; exit 1 ;;
esac
fi
if [ "$PENTEST_STATUS" = "required-not-completed" ] && [ "$PRIVATE_TEST_RELEASE" = "0" ]; then
echo ""
echo "The required pentest is incomplete. This exception is only permitted"
echo "for a documented test release while the GitHub repository is private."
if yes_no "Continue with the private test release exception?" no; then
PRIVATE_TEST_RELEASE="1"
fi
fi
fi
node scripts/release-security-policy.mjs \
"$SECURITY_CHECKLIST_VERSION" \
"$SECURITY_REVIEW_REF" \
"$RESIDUAL_RISKS" \
"$PENTEST_STATUS" \
"$PRIVATE_TEST_RELEASE" >/dev/null
fi
DEPLOYED_SHA=""
if [ "$CORE" -eq 1 ] || [ "$WEB" -eq 1 ]; then
require_gh
DEPLOYED_SHA=$(git rev-parse HEAD)
echo "→ Waiting for main CI on $(git rev-parse --short HEAD)..."
wait_for_run ci.yml "$DEPLOYED_SHA" push main
echo "→ Requesting protected promotion and Cloud deployment..."
PRIVATE_TEST_INPUT="false"
[ "$PRIVATE_TEST_RELEASE" = "1" ] && PRIVATE_TEST_INPUT="true"
dispatch_and_wait promote-production.yml main "$DEPLOYED_SHA" \
-f sha="$DEPLOYED_SHA" \
-f checklist_version="$SECURITY_CHECKLIST_VERSION" \
-f security_review_ref="$SECURITY_REVIEW_REF" \
-f residual_risks="$RESIDUAL_RISKS" \
-f pentest_status="$PENTEST_STATUS" \
-f private_test_release="$PRIVATE_TEST_INPUT"
git fetch origin production
if [ "$(git rev-parse origin/production)" != "$DEPLOYED_SHA" ]; then
echo "Error: production does not point to the verified SHA $DEPLOYED_SHA."
exit 1
fi
fi
if [ "$CORE" -eq 1 ]; then
echo "→ Publishing public core v$TARGET_VERSION from production..."
dispatch_and_wait release.yml production "$DEPLOYED_SHA" -f version="$TARGET_VERSION" -f sha="$DEPLOYED_SHA"
fi
if [ "$DESKTOP" -eq 1 ]; then
DESKTOP_VERSION="${TARGET_VERSION:-$(node -p "require('./package.json').version")}"
if ! gh release view "v$DESKTOP_VERSION" >/dev/null 2>&1; then
echo "Error: desktop applications need an existing public core release v$DESKTOP_VERSION."
exit 1
fi
# The public-release environment accepts only `production` and release tags,
# whichever platforms are published.
git fetch origin production
DESKTOP_WORKFLOW_REF="production"
DESKTOP_RUN_SHA=$(git rev-parse origin/production)
if [ "$DESKTOP_TARGET" = "windows" ]; then
echo "→ Publishing only Microsoft Store packages for v$DESKTOP_VERSION..."
else
echo "→ Publishing Linux and Microsoft Store desktop applications, v$DESKTOP_VERSION..."
fi
dispatch_and_wait desktop-release.yml "$DESKTOP_WORKFLOW_REF" "$DESKTOP_RUN_SHA" \
-f version="$DESKTOP_VERSION" \
-f target="$DESKTOP_TARGET"
if [ "$DESKTOP_TARGET" = "all" ]; then
echo "→ macOS is next, from this Mac: build, notarize and publish it, then record"
echo " desktop/released.json in a pull request (docs/desktop-release.md)."
echo " The record changes no deployed file and needs no promotion of its own."
fi
fi
echo ""
echo "✓ Publication finished: core=$CORE · web=$WEB · desktop=$DESKTOP"
if [ -n "$DEPLOYED_SHA" ]; then
echo " production : $DEPLOYED_SHA"
fi
if [ -n "$TARGET_VERSION" ]; then
echo " tag : v$TARGET_VERSION → $DEPLOYED_SHA"
fi