123set -Eeuo pipefail45ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"6CHECK_ONLY=false7ALLOW_DIRTY=false8ASSUME_YES=false9PHASE="initialization"1011usage() {12 cat <<'EOF'13Usage: ./release.sh [--check] [--allow-dirty] [-y]1415 --check Run the local gate without Cloudflare authentication or deployment.16 --allow-dirty Permit a dirty worktree in --check mode only.17 -y, --yes Skip the interactive production confirmation.18EOF19}2021die() {22 printf '\nRelease stopped during %s: %s\n' "$PHASE" "$*" >&223 exit 124}2526on_error() {27 local status=$?28 printf '\nRelease failed during %s (exit %s).\n' "$PHASE" "$status" >&229 if [[ "$CHECK_ONLY" == false ]]; then30 printf '%s\n' 'Production may be partially updated. Fix the cause and rerun this exact commit.' >&231 printf '%s\n' 'Do not roll back D1 migrations automatically; Worker versions can be rolled back independently.' >&232 fi33 exit "$status"34}35trap on_error ERR3637while (($#)); do38 case "$1" in39 --check) CHECK_ONLY=true ;;40 --allow-dirty) ALLOW_DIRTY=true ;;41 -y|--yes) ASSUME_YES=true ;;42 -h|--help) usage; exit 0 ;;43 *) usage >&2; die "unknown option: $1" ;;44 esac45 shift46done4748if [[ "$ALLOW_DIRTY" == true && "$CHECK_ONLY" == false ]]; then49 die '--allow-dirty is restricted to --check; production releases always require a clean worktree.'50fi5152cd "$ROOT"5354section() {55 PHASE="$1"56 printf '\n==> %s\n' "$PHASE"57}5859run_in() {60 local directory=$161 shift62 local label="${directory#"$ROOT"/}"63 [[ "$directory" == "$ROOT" ]] && label='.'64 printf ' [%s] ' "$label"65 printf '%q ' "$@"66 printf '\n'67 (cd "$directory" && "$@")68}6970require_command() {71 command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"72}7374workspace_fingerprint() {75 {76 git diff --binary HEAD77 while IFS= read -r -d '' file; do78 shasum -a 256 "$file"79 done < <(git ls-files --others --exclude-standard -z)80 } | shasum -a 256 | awk '{print $1}'81}8283retry() {84 local attempts=$185 local delay=$286 shift 287 local attempt=188 until "$@"; do89 if ((attempt >= attempts)); then return 1; fi90 printf ' Attempt %s/%s failed; retrying in %ss.\n' "$attempt" "$attempts" "$delay" >&291 sleep "$delay"92 attempt=$((attempt + 1))93 done94}9596section 'repository checks'97require_command git98require_command node99require_command npm100require_command uv101require_command shasum102require_command curl103104[[ "$(git rev-parse --show-toplevel)" == "$ROOT" ]] || die 'release.sh must run from its own repository.'105[[ "$(git branch --show-current)" == main ]] || die 'production releases must come from main.'106107if [[ "$ALLOW_DIRTY" == false && -n "$(git status --porcelain=v1)" ]]; then108 git status --short >&2109 die 'the worktree must be clean, including untracked files.'110fi111112NODE_MAJOR="$(node -p 'Number(process.versions.node.split(".")[0])')"113((NODE_MAJOR >= 24)) || die "Node.js 24 or newer is required; found $(node --version)."114START_FINGERPRINT="$(workspace_fingerprint)"115RELEASE_SHA="$(git rev-parse HEAD)"116RELEASE_SHORT_SHA="$(git rev-parse --short=12 HEAD)"117RELEASE_SUBJECT="$(git log -1 --pretty=%s)"118RELEASE_MESSAGE="diagram.zip ${RELEASE_SHORT_SHA}: ${RELEASE_SUBJECT}"119120NPM_DIRECTORIES=("$ROOT")121while IFS= read -r lockfile; do122 directory="$(cd "$(dirname "$lockfile")" && pwd -P)"123 [[ "$directory" == "$ROOT" ]] || NPM_DIRECTORIES+=("$directory")124done < <(find apps/docs services renderers/browser-run renderers/edge -name package-lock.json -not -path '*/node_modules/*' -print | sort)125126PYTHON_DIRECTORIES=()127while IFS= read -r config; do128 PYTHON_DIRECTORIES+=("$(cd "$(dirname "$config")" && pwd -P)")129done < <(find renderers/python -mindepth 2 -maxdepth 2 -name wrangler.jsonc -print | sort)130131BROWSER_FRAME_DIRECTORIES=()132while IFS= read -r config; do133 BROWSER_FRAME_DIRECTORIES+=("$(cd "$(dirname "$config")" && pwd -P)")134done < <(find renderers/browser-run/frames -mindepth 2 -maxdepth 2 -name wrangler.jsonc -print | sort)135136BROWSER_RUN_DIRECTORY="$ROOT/renderers/browser-run"137138EDGE_CONFIG_COUNT="$(find renderers/edge/config/units -maxdepth 1 -name '*.jsonc' | wc -l | tr -d ' ')"139DEPLOYMENT_COUNT=$((EDGE_CONFIG_COUNT + ${#PYTHON_DIRECTORIES[@]} + ${#BROWSER_FRAME_DIRECTORIES[@]} + 5))140WRANGLER_CONFIG_COUNT="$(find apps services renderers \141 -path '*/node_modules' -prune -o \142 -path 'renderers/edge/config' -prune -o \143 -name wrangler.jsonc -print | wc -l | tr -d ' ')"144PRODUCTION_CONFIG_COUNT=$((WRANGLER_CONFIG_COUNT + EDGE_CONFIG_COUNT))145[[ "$PRODUCTION_CONFIG_COUNT" == "$DEPLOYMENT_COUNT" ]] || \146 die "release ownership covers ${DEPLOYMENT_COUNT} deployments, but ${PRODUCTION_CONFIG_COUNT} production Wrangler configs exist."147printf ' Commit: %s (%s)\n' "$RELEASE_SHORT_SHA" "$RELEASE_SUBJECT"148printf ' Discovered: %s edge + %s Python + %s Browser Run frames + Browser Run/catalog/API/shell/docs = %s deployments\n' \149 "$EDGE_CONFIG_COUNT" "${#PYTHON_DIRECTORIES[@]}" "${#BROWSER_FRAME_DIRECTORIES[@]}" "$DEPLOYMENT_COUNT"150151section 'locked dependency installation'152for directory in "${NPM_DIRECTORIES[@]}"; do153 run_in "$directory" npm ci154done155for directory in "${PYTHON_DIRECTORIES[@]}"; do156 run_in "$directory" uv lock --check157 run_in "$directory" uv sync --locked158done159160WRANGLER="$ROOT/renderers/edge/node_modules/.bin/wrangler"161[[ -x "$WRANGLER" ]] || die 'the locked Wrangler installation is unavailable.'162[[ "$($WRANGLER --version)" == 4.* ]] || die 'Wrangler 4.x is required.'163164section 'generated types and static checks'165run_in "$ROOT/services/api" "$ROOT/services/api/node_modules/.bin/wrangler" types --check166run_in "$ROOT/services/shell" "$ROOT/services/shell/node_modules/.bin/wrangler" types --check167run_in "$ROOT/renderers/edge" "$WRANGLER" types ../catalog/worker-configuration.d.ts --config ../catalog/wrangler.jsonc --check168run_in "$ROOT/services/api" npm run check169run_in "$ROOT/services/shell" npm run check170run_in "$ROOT/renderers/edge" npm run check171run_in "$BROWSER_RUN_DIRECTORY" npm run check172173section 'tests and production builds'174run_in "$ROOT" npm test175run_in "$ROOT" npm run build:editor176run_in "$ROOT" npm run build:docs177run_in "$BROWSER_RUN_DIRECTORY" npm test178for directory in "${PYTHON_DIRECTORIES[@]}"; do179 run_in "$directory" uv run pytest -q180 run_in "$directory" uv run pywrangler sync --force181done182for directory in "${BROWSER_FRAME_DIRECTORIES[@]}"; do183 if node -e 'const fs=require("node:fs"); const p=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.exit(p.scripts?.test ? 0 : 1)' "$directory/package.json"; then184 run_in "$directory" npm test185 else186 run_in "$directory" npm run build187 fi188done189190section 'artifact and deployment dry runs'191run_in "$ROOT/renderers/edge" npm run check:licenses192run_in "$ROOT/renderers/catalog" "$WRANGLER" deploy --dry-run --strict --config wrangler.jsonc193run_in "$ROOT/services/api" "$ROOT/services/api/node_modules/.bin/wrangler" deploy --dry-run --strict --config wrangler.jsonc194run_in "$ROOT/services/shell" "$ROOT/services/shell/node_modules/.bin/wrangler" deploy --dry-run --strict --config wrangler.jsonc195run_in "$BROWSER_RUN_DIRECTORY" "$BROWSER_RUN_DIRECTORY/node_modules/.bin/wrangler" deploy --dry-run --strict --config wrangler.jsonc196for directory in "${BROWSER_FRAME_DIRECTORIES[@]}"; do197 run_in "$directory" "$WRANGLER" deploy --dry-run --strict --config wrangler.jsonc198done199200section 'generated-file drift check'201END_FINGERPRINT="$(workspace_fingerprint)"202if [[ "$START_FINGERPRINT" != "$END_FINGERPRINT" ]]; then203 git status --short >&2204 die 'the release gate changed tracked or untracked repository content; regenerate and review it before release.'205fi206207if [[ "$CHECK_ONLY" == true ]]; then208 printf '\nRelease gate passed for %s. No remote commands were run.\n' "$RELEASE_SHORT_SHA"209 exit 0210fi211212section 'Cloudflare authentication and remote preflight'213run_in "$ROOT/renderers/edge" "$WRANGLER" whoami214run_in "$ROOT/services/api" "$ROOT/services/api/node_modules/.bin/wrangler" d1 migrations list diagramzip --remote --config wrangler.jsonc215216if [[ "$ASSUME_YES" == true ]]; then217 printf '\nDeploying %s to production across %s Cloudflare deployments (-y).\n' "$RELEASE_SHORT_SHA" "$DEPLOYMENT_COUNT"218else219 [[ -t 0 ]] || die 'production confirmation requires an interactive terminal; pass -y to skip it.'220 printf '\nDeploy %s to production across %s Cloudflare deployments? [y/N] ' "$RELEASE_SHORT_SHA" "$DEPLOYMENT_COUNT"221 read -r answer222 [[ "$answer" == y || "$answer" == Y ]] || die 'production release cancelled.'223fi224225export DIAGRAMZIP_RELEASE_MESSAGE="$RELEASE_MESSAGE"226227section 'edge renderer deployment'228run_in "$ROOT/renderers/edge" npm run deploy:edge229230section 'Python renderer deployment'231run_in "$ROOT/renderers/edge" npm run deploy:python232233section 'private Browser Run deployment'234run_in "$BROWSER_RUN_DIRECTORY" "$BROWSER_RUN_DIRECTORY/node_modules/.bin/wrangler" deploy --strict --message "$RELEASE_MESSAGE" --config wrangler.jsonc235236section 'browser-backed renderer deployment'237for directory in "${BROWSER_FRAME_DIRECTORIES[@]}"; do238 run_in "$directory" "$WRANGLER" deploy --strict --message "$RELEASE_MESSAGE" --config wrangler.jsonc239done240241section 'renderer catalog deployment'242run_in "$ROOT/renderers/catalog" "$WRANGLER" deploy --strict --message "$RELEASE_MESSAGE" --config wrangler.jsonc243244section 'D1 migrations and API deployment'245run_in "$ROOT/services/api" "$ROOT/services/api/node_modules/.bin/wrangler" d1 migrations apply diagramzip --remote --config wrangler.jsonc246run_in "$ROOT/services/api" "$ROOT/services/api/node_modules/.bin/wrangler" deploy --strict --message "$RELEASE_MESSAGE" --config wrangler.jsonc247248section 'editor shell deployment'249run_in "$ROOT/services/shell" "$ROOT/services/shell/node_modules/.bin/wrangler" deploy --strict --message "$RELEASE_MESSAGE" --config wrangler.jsonc250251section 'documentation deployment'252run_in "$ROOT/apps/docs" "$ROOT/apps/docs/node_modules/.bin/wrangler" pages deploy build \253 --project-name diagramzip-docs \254 --branch main \255 --commit-hash "$RELEASE_SHA" \256 --commit-message "$RELEASE_SUBJECT" \257 --commit-dirty=false258259section 'production smoke tests'260retry 3 5 run_in "$ROOT/renderers/edge" npm run smoke:renderers261retry 3 5 env DIAGRAMZIP_RENDER_URL=https://diagram.zip node "$ROOT/renderers/catalog/scripts/smoke.mjs"262retry 6 5 curl --fail --silent --show-error --output /dev/null https://diagram.zip/api/v1/health263retry 6 5 curl --fail --silent --show-error --output /dev/null https://diagram.zip/healthz264retry 6 5 curl --fail --silent --show-error --output /dev/null https://docs.diagram.zip/265266printf '\nRelease %s completed: %s deployments and all public smoke tests passed.\n' \267 "$RELEASE_SHORT_SHA" "$DEPLOYMENT_COUNT"