#!/usr/bin/env bash set -euo pipefail required() { local name="$1" if [ -z "${!name:-}" ]; then echo "$name is required" >&2 exit 1 fi } format_duration() { local ms="$1" if [ "$ms" -ge 60000 ]; then node -e 'process.stdout.write(`${Math.round((Number(process.argv[1]) / 60000) * 10) / 10}m`)' "$ms" else node -e 'process.stdout.write(`${Math.round((Number(process.argv[1]) / 1000) * 10) / 10}s`)' "$ms" fi } measure_step() { local name="$1" shift echo "##[group]$name" local started duration started="$(node -e 'process.stdout.write(String(Date.now()))')" if "$@"; then duration="$(( $(node -e 'process.stdout.write(String(Date.now()))') - started ))" release_timings_json="${release_timings_json:+$release_timings_json,}{\"step\":$(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$name"),\"status\":\"success\",\"durationMs\":$duration}" echo "[$name] success in $(format_duration "$duration")" else local status=$? duration="$(( $(node -e 'process.stdout.write(String(Date.now()))') - started ))" release_timings_json="${release_timings_json:+$release_timings_json,}{\"step\":$(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$name"),\"status\":\"failed\",\"durationMs\":$duration}" echo "[$name] failed in $(format_duration "$duration")" echo "##[endgroup]" return "$status" fi echo "##[endgroup]" } is_transient_notary_output() { local output="$1" case "$output" in *abortedUpload* | *deadlineExceeded* | *ECONNRESET* | *ETIMEDOUT* | *ENOTFOUND* | *EAI_AGAIN* | *"socket hang up"* | *"network connection was lost"*) return 0 ;; *) return 1 ;; esac } notary_auth_args() { if [ -n "${APPLE_NOTARY_KEYCHAIN_PROFILE:-}" ]; then if [ -n "${APPLE_NOTARY_KEYCHAIN:-}" ]; then printf '%s\0' --keychain "$APPLE_NOTARY_KEYCHAIN" fi printf '%s\0' --keychain-profile "$APPLE_NOTARY_KEYCHAIN_PROFILE" else printf '%s\0' --apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID" fi } notarize_mac_dmg_once() { local dmg_path="$1" local auth_args=() local s3_arg="--no-s3-acceleration" local status if [ "${OPEN_DESIGN_NOTARIZE_S3_ACCELERATION:-false}" = "true" ]; then s3_arg="--s3-acceleration" fi while IFS= read -r -d '' arg; do auth_args+=("$arg") done < <(notary_auth_args) echo "[release notarize] submitting $(basename "$dmg_path") ($(wc -c < "$dmg_path" | tr -d ' ') bytes) with $s3_arg" xcrun notarytool submit "$dmg_path" \ "${auth_args[@]}" \ --wait \ --output-format json \ "$s3_arg" status=$? if [ "$status" -ne 0 ]; then return "$status" fi xcrun stapler staple "$dmg_path" } notarize_mac_dmg() { local dmg_path="$1" local attempts="${OPEN_DESIGN_NOTARIZE_ATTEMPTS:-8}" local retry_delay_ms="${OPEN_DESIGN_NOTARIZE_RETRY_DELAY_MS:-15000}" local attempt output status if [ ! -f "$dmg_path" ]; then echo "expected dmg not found for notarization: $dmg_path" >&2 return 1 fi for ((attempt = 1; attempt <= attempts; attempt += 1)); do set +e output="$(notarize_mac_dmg_once "$dmg_path" 2>&1)" status=$? set -e printf '%s\n' "$output" if [ "$status" -eq 0 ]; then return 0 fi if [ "$attempt" -lt "$attempts" ] && is_transient_notary_output "$output"; then echo "[release notarize] transient notarytool failure on attempt $attempt/$attempts; retrying in ${retry_delay_ms}ms" >&2 sleep "$(node -e 'process.stdout.write(String(Number(process.argv[1]) / 1000))' "$retry_delay_ms")" else return "$status" fi done } prepare_mac_signing() { if [ -n "${CSC_KEYCHAIN:-}" ]; then unset CSC_LINK unset CSC_KEY_PASSWORD return 0 fi required APPLE_SIGNING_CERTIFICATE_BASE64 required APPLE_SIGNING_CERTIFICATE_PASSWORD local cert_path="$RELEASE_WORK_DIR/open-design-signing.p12" if ! printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 --decode > "$cert_path" 2>/dev/null; then printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 -D > "$cert_path" fi export CSC_LINK="$cert_path" export CSC_KEY_PASSWORD="$APPLE_SIGNING_CERTIFICATE_PASSWORD" } prepare_mac_notarization() { required APPLE_ID required APPLE_TEAM_ID if [ -z "${APPLE_NOTARY_KEYCHAIN_PROFILE:-}" ]; then required APPLE_APP_SPECIFIC_PASSWORD fi } select_mac_signing_identity() { local keychain_path="${CSC_KEYCHAIN:-}" if [ -z "$keychain_path" ]; then return 0 fi local identities identity_name csc_name identities="$(security find-identity -v -p codesigning "$keychain_path")" printf '%s\n' "$identities" identity_name="$(printf '%s\n' "$identities" | sed -n 's/.*"\(Developer ID Application:[^"]*\)".*/\1/p' | head -n 1)" if [ -z "$identity_name" ]; then echo "mac signing failed: no Developer ID Application identity found in $keychain_path" >&2 exit 1 fi csc_name="${identity_name#Developer ID Application: }" export CSC_NAME="$csc_name" echo "mac signing: selected CSC_NAME=$CSC_NAME" } write_outputs() { node --input-type=module <<'NODE' import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; const build = JSON.parse(readFileSync(process.env.BUILD_JSON_PATH, "utf8")); const outputs = { build_json_path: process.env.BUILD_JSON_PATH, build_log_path: process.env.BUILD_LOG_PATH ?? "", release_target: process.env.RELEASE_TARGET, release_version: process.env.RELEASE_VERSION, ...(build.appPath ? { app_path: build.appPath } : {}), ...(build.dmgPath ? { dmg_path: build.dmgPath } : {}), ...(build.payloadPath ? { payload_path: build.payloadPath } : {}), ...(build.zipPath ? { zip_path: build.zipPath } : {}), ...(build.outputRoot ? { output_root: build.outputRoot } : {}), }; mkdirSync(dirname(process.env.RELEASE_OUTPUTS_PATH), { recursive: true }); writeFileSync(process.env.RELEASE_OUTPUTS_PATH, `${JSON.stringify(outputs, null, 2)}\n`, "utf8"); NODE } required BUILD_JSON_PATH required BUILD_LOG_PATH required RELEASE_BUILD_TARGET required RELEASE_CHANNEL required RELEASE_NAMESPACE required RELEASE_OUTPUTS_PATH required RELEASE_SMOKE_MODE required RELEASE_TARGET required RELEASE_VERSION required RELEASE_WORK_DIR required TOOLS_PACK_CACHE_DIR required TOOLS_PACK_DIR case "$RELEASE_SMOKE_MODE" in skip | core | full) ;; *) echo "unsupported RELEASE_SMOKE_MODE: $RELEASE_SMOKE_MODE" >&2; exit 1 ;; esac mkdir -p "$RELEASE_WORK_DIR" "$TOOLS_PACK_CACHE_DIR" "$(dirname "$BUILD_JSON_PATH")" "$(dirname "$BUILD_LOG_PATH")" : > "$BUILD_LOG_PATH" release_timings_json="" release_channel="$RELEASE_CHANNEL" resolve_local_update_version() { local channel="$1" local version="$2" if [ "$channel" = "stable" ]; then if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then printf '%s.%s.%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "$((BASH_REMATCH[3] + 1))" return 0 fi echo "full mac stable smoke requires stable version x.y.z; got $version" >&2 return 1 fi if [[ "$version" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-${channel}\.([0-9]+)$ ]]; then printf '%s-%s.%s' "${BASH_REMATCH[1]}" "$channel" "$((BASH_REMATCH[2] + 1))" return 0 fi echo "full mac payload smoke requires counted version x.y.z-$channel.N; got $version" >&2 return 1 } case "$RELEASE_TARGET" in mac_arm64 | mac_x64) sign_mode="${RELEASE_SIGN_MODE:-no}" case "$sign_mode" in no | sign-only | notarize) ;; *) echo "unsupported RELEASE_SIGN_MODE: $sign_mode" >&2; exit 1 ;; esac case "$RELEASE_BUILD_TARGET" in dmg | all) ;; *) echo "unsupported mac RELEASE_BUILD_TARGET: $RELEASE_BUILD_TARGET" >&2; exit 1 ;; esac if [ "$sign_mode" != "no" ]; then measure_step "prepare mac signing" prepare_mac_signing measure_step "select mac signing identity" select_mac_signing_identity fi if [ "$sign_mode" = "notarize" ]; then measure_step "prepare mac notarization" prepare_mac_notarization fi rm -rf "$TOOLS_PACK_DIR" build_args=( exec tools-pack mac build --dir "$TOOLS_PACK_DIR" --cache-dir "$TOOLS_PACK_CACHE_DIR" --namespace "$RELEASE_NAMESPACE" --portable --app-version "$RELEASE_VERSION" --mac-compression "${MAC_COMPRESSION:-normal}" --to "$RELEASE_BUILD_TARGET" --json ) if [ "${REQUIRE_VELA_CLI:-false}" = "true" ]; then build_args+=(--require-vela-cli) fi if [ "$sign_mode" != "no" ]; then build_args+=(--signed) fi ;; linux_x64) if [ "$RELEASE_BUILD_TARGET" != "appimage" ]; then echo "unsupported linux_x64 RELEASE_BUILD_TARGET: $RELEASE_BUILD_TARGET" >&2 exit 1 fi rm -rf "$TOOLS_PACK_DIR" build_args=( exec tools-pack linux build --dir "$TOOLS_PACK_DIR" --cache-dir "$TOOLS_PACK_CACHE_DIR" --namespace "$RELEASE_NAMESPACE" --portable --app-version "$RELEASE_VERSION" --to appimage --containerized --json ) ;; *) echo "unsupported RELEASE_TARGET for build-platform.sh: $RELEASE_TARGET" >&2 exit 1 ;; esac if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$BUILD_LOG_PATH" >&2))"; then printf '%s\n' "$build_output" | tee "$BUILD_JSON_PATH" if [ "${sign_mode:-no}" = "notarize" ]; then dmg_path="$(BUILD_JSON_PATH="$BUILD_JSON_PATH" node --input-type=module <<'NODE' import { readFileSync } from "node:fs"; const build = JSON.parse(readFileSync(process.env.BUILD_JSON_PATH, "utf8")); process.stdout.write(build.dmgPath ?? ""); NODE )" measure_step "notarize mac dmg" notarize_mac_dmg "$dmg_path" fi BUILD_JSON_PATH="$BUILD_JSON_PATH" RELEASE_TIMINGS_JSON="[$release_timings_json]" node --input-type=module <<'NODE' import { readFileSync, writeFileSync } from "node:fs"; const path = process.env.BUILD_JSON_PATH; const build = JSON.parse(readFileSync(path, "utf8")); build.releaseScriptTimings = JSON.parse(process.env.RELEASE_TIMINGS_JSON ?? "[]"); writeFileSync(path, `${JSON.stringify(build, null, 2)}\n`, "utf8"); NODE else build_status=$? printf '%s\n' "$build_output" exit "$build_status" fi if [ "$RELEASE_SMOKE_MODE" = "skip" ]; then echo "Skipping $RELEASE_TARGET packaged runtime smoke: smoke mode skip" elif [ "$RELEASE_TARGET" = "linux_x64" ]; then required RELEASE_REPORT_DIR mkdir -p "$RELEASE_REPORT_DIR/screenshots" OD_PACKAGED_E2E_LINUX_APPIMAGE=1 \ OD_PACKAGED_E2E_NAMESPACE="$RELEASE_NAMESPACE" \ OD_PACKAGED_E2E_SCREENSHOT_PATH="$RELEASE_REPORT_DIR/screenshots/open-design-linux-smoke.png" \ OD_PACKAGED_E2E_TOOLS_PACK_DIR="$TOOLS_PACK_DIR" \ pnpm --dir e2e test specs/linux.spec.ts 2>&1 | tee "$RELEASE_REPORT_DIR/vitest.log" else required RELEASE_REPORT_DIR update_build_json_path="" update_version="" if [ "$RELEASE_SMOKE_MODE" = "full" ] && [ -z "${OD_PACKAGED_E2E_MAC_UPDATE_METADATA_URL:-}" ]; then if [ "$RELEASE_TARGET" != "mac_arm64" ]; then echo "local tools-serve mac updater fixture is only supported for mac_arm64; provide OD_PACKAGED_E2E_MAC_UPDATE_METADATA_URL for $RELEASE_TARGET full smoke" >&2 exit 1 fi update_version="$(resolve_local_update_version "$release_channel" "$RELEASE_VERSION")" update_fixture_dir="$RELEASE_WORK_DIR/tools-pack-update-fixture" update_build_json_path="$RELEASE_WORK_DIR/mac-tools-pack-update-build.json" update_args=( exec tools-pack mac build --dir "$update_fixture_dir" --cache-dir "$TOOLS_PACK_CACHE_DIR" --namespace "$RELEASE_NAMESPACE" --portable --app-version "$update_version" --mac-compression "${MAC_COMPRESSION:-normal}" --to dmg --json ) build_mac_update_fixture() { local update_output update_output="$(pnpm "${update_args[@]}")" printf '%s\n' "$update_output" > "$update_build_json_path" } measure_step "tools-pack mac build update fixture" build_mac_update_fixture fi OD_PACKAGED_E2E_BUILD_JSON_PATH="$BUILD_JSON_PATH" \ OD_PACKAGED_E2E_BUILD_LOG_PATH="$BUILD_LOG_PATH" \ OD_PACKAGED_E2E_MAC=1 \ OD_PACKAGED_E2E_MAC_UPDATE_BUILD_JSON_PATH="$update_build_json_path" \ OD_PACKAGED_E2E_MAC_UPDATE_FIXTURE="${update_build_json_path:+tools-serve}" \ OD_PACKAGED_E2E_MAC_UPDATE_METADATA_URL="${OD_PACKAGED_E2E_MAC_UPDATE_METADATA_URL:-}" \ OD_PACKAGED_E2E_MAC_UPDATE_VERSION="${OD_PACKAGED_E2E_MAC_UPDATE_VERSION:-$update_version}" \ OD_PACKAGED_E2E_MAC_SMOKE_PROFILE="$RELEASE_SMOKE_MODE" \ OD_PACKAGED_E2E_NAMESPACE="$RELEASE_NAMESPACE" \ OD_PACKAGED_E2E_RELEASE_CHANNEL="$release_channel" \ OD_PACKAGED_E2E_RELEASE_VERSION="$RELEASE_VERSION" \ OD_PACKAGED_E2E_REPORT_DIR="$RELEASE_REPORT_DIR" \ OD_PACKAGED_E2E_TOOLS_PACK_DIR="$TOOLS_PACK_DIR" \ pnpm --dir e2e exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts fi write_outputs