deployment.sh (36346B)
1 #!/usr/bin/env bash 2 set -euo pipefail 3 4 cd "$(dirname "${BASH_SOURCE[0]}")" 5 6 RELEASE_ROLLBACK_ARMED=false 7 RELEASE_START_HEAD="" 8 CARGO_FUZZ_VERSION="0.13.2" 9 TAURI_CLI_VERSION="2.11.5" 10 RELEASE_VERSION_FILES=( 11 CHANGELOG.md 12 Cargo.toml 13 Cargo.lock 14 fuzz/Cargo.lock 15 src-tauri/Cargo.toml 16 src-tauri/Cargo.lock 17 src-tauri/tauri.conf.json 18 README.md 19 ) 20 21 usage() { 22 echo "Usage:" 23 echo " $0 [--genesis] [--skip-long-tests] <version>" 24 echo " $0 --website-only" 25 echo 26 echo "Options:" 27 echo " --genesis Start a new chain and permanently replace the node PVC" 28 echo " --skip-long-tests Skip long-running release test suites" 29 echo " --website-only Deploy the website from the current commit without a release" 30 echo " -h, --help Show this help" 31 echo 32 echo "Examples:" 33 echo " $0 0.4.7" 34 echo " $0 --skip-long-tests 0.4.7" 35 echo " $0 --genesis 0.4.7" 36 echo " $0 --website-only" 37 } 38 39 die() { 40 echo "error: $*" >&2 41 exit 1 42 } 43 44 require_command() { 45 local command_name="$1" 46 47 command -v "$command_name" >/dev/null 2>&1 || die "missing required command: ${command_name}" 48 } 49 50 require_cargo_fuzz() { 51 require_command cargo-fuzz 52 require_command rustup 53 54 local installed_version 55 installed_version="$(cargo fuzz --version)" 56 [ "$installed_version" = "cargo-fuzz ${CARGO_FUZZ_VERSION}" ] || \ 57 die "cargo-fuzz ${CARGO_FUZZ_VERSION} is required, found ${installed_version}" 58 rustup run nightly rustc --version >/dev/null 2>&1 || \ 59 die "a nightly Rust toolchain is required for coverage-guided fuzzing" 60 } 61 62 run_fuzz_campaign() { 63 local target="$1" 64 local seconds="$2" 65 local evidence_dir="$3" 66 local corpus_dir="${evidence_dir}/corpus/${target}" 67 local artifact_dir="${evidence_dir}/artifacts/${target}" 68 69 mkdir -p "$corpus_dir" "$artifact_dir" 70 cp -R "fuzz/corpus/${target}/." "$corpus_dir/" 71 cargo +nightly fuzz run "$target" "$corpus_dir" -- \ 72 -max_total_time="$seconds" \ 73 -timeout=10 \ 74 -artifact_prefix="${artifact_dir}/" 75 } 76 77 docker_native_linux_platform() { 78 local architecture 79 80 # Keep rustc native to the Docker engine; cross-compile only the release binaries. 81 architecture="$(docker info --format '{{.Architecture}}')" || die "could not determine Docker engine architecture" 82 case "$architecture" in 83 amd64|x86_64) 84 printf '%s\n' linux/amd64 85 ;; 86 arm64|aarch64) 87 printf '%s\n' linux/arm64 88 ;; 89 *) 90 die "unsupported Docker engine architecture: ${architecture}" 91 ;; 92 esac 93 } 94 95 is_apple_silicon_macos() { 96 [ "$(uname -s)" = "Darwin" ] || return 1 97 [ "$(uname -m)" = "arm64" ] && return 0 98 [ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" = "1" ] 99 } 100 101 confirm() { 102 local prompt="$1" 103 local answer 104 105 if ! read -r -p "$prompt" answer || [[ ! "$answer" =~ ^[Yy]$ ]]; then 106 return 1 107 fi 108 } 109 110 escape_sed_replacement() { 111 printf '%s' "$1" | sed -e 's/[\/&]/\\&/g' 112 } 113 114 replace_in_file() { 115 local file="$1" 116 local pattern="$2" 117 local replacement="$3" 118 perl -0pi -e "s|${pattern}|${replacement}|g" "$file" 119 } 120 121 validate_positive_integer() { 122 local name="$1" 123 local value="$2" 124 125 [[ "$value" =~ ^[1-9][0-9]*$ ]] || die "${name} must be a positive integer" 126 } 127 128 ensure_clean_worktree() { 129 require_command git 130 131 if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then 132 die "worktree is not clean; commit or stash changes before deploying or releasing" 133 fi 134 } 135 136 ensure_head_matches_tag() { 137 local tag="$1" 138 local head_commit 139 local tag_commit 140 141 head_commit="$(git rev-parse HEAD)" 142 tag_commit="$(git rev-parse "${tag}^{commit}")" 143 [ "$head_commit" = "$tag_commit" ] || die "${tag} exists, but HEAD is not at ${tag}; checkout ${tag} before redeploying it" 144 } 145 146 arm_release_rollback() { 147 RELEASE_START_HEAD="$(git rev-parse HEAD)" 148 RELEASE_ROLLBACK_ARMED=true 149 trap 'rollback_release_changes "$?"' EXIT 150 } 151 152 disarm_release_rollback() { 153 RELEASE_ROLLBACK_ARMED=false 154 } 155 156 rollback_release_changes() { 157 local exit_status="$1" 158 local current_head 159 160 [ "$exit_status" -ne 0 ] || return 0 161 [ "$RELEASE_ROLLBACK_ARMED" = "true" ] || return 0 162 163 current_head="$(git rev-parse HEAD 2>/dev/null || true)" 164 if [ "$current_head" != "$RELEASE_START_HEAD" ]; then 165 echo "WARNING: release failed after HEAD changed; version changes were not rolled back" >&2 166 return 0 167 fi 168 169 if git restore --source="$RELEASE_START_HEAD" --staged --worktree -- "${RELEASE_VERSION_FILES[@]}"; then 170 echo "Release failed; restored version and changelog files to $(git rev-parse --short "$RELEASE_START_HEAD")" >&2 171 else 172 echo "WARNING: release failed and version changes could not be restored automatically" >&2 173 fi 174 } 175 176 ensure_tauri_cli() { 177 require_command cargo 178 179 if [ "$(cargo tauri --version 2>/dev/null || true)" != "tauri-cli ${TAURI_CLI_VERSION}" ]; then 180 cargo install tauri-cli --locked --version "=${TAURI_CLI_VERSION}" 181 fi 182 } 183 184 update_signing_key() { 185 local key="${IUNA_UPDATE_SIGNING_KEY:-config/update-signing.key}" 186 [ -f "$key" ] || die "missing update signing key: ${key}; restore it from the secure release-key backup" 187 ( 188 cd "$(dirname "$key")" 189 printf '%s/%s\n' "$(pwd)" "$(basename "$key")" 190 ) 191 } 192 193 validate_update_public_key() { 194 local configured_key 195 local committed_key 196 197 require_command jq 198 configured_key="$(jq -r '.plugins.updater.pubkey' src-tauri/tauri.conf.json)" 199 committed_key="$(tr -d '\r\n' < config/update-signing.key.pub)" 200 [ -n "$configured_key" ] || die "desktop updater public key is empty" 201 [ "$configured_key" = "$committed_key" ] || \ 202 die "src-tauri/tauri.conf.json updater key does not match config/update-signing.key.pub" 203 } 204 205 clear_nsis_installers() { 206 local nsis_dir="$1" 207 208 mkdir -p "$nsis_dir" 209 find "$nsis_dir" -maxdepth 1 -type f -name '*-setup.exe' -delete 210 } 211 212 versioned_nsis_installer() { 213 local nsis_dir="$1" 214 local version="$2" 215 local installer="${nsis_dir}/iuna_${version}_x64-setup.exe" 216 217 [ -f "$installer" ] || die "Windows installer for version ${version} was not produced at ${installer}" 218 printf '%s\n' "$installer" 219 } 220 221 run_release_tests() { 222 local skip_long_tests="$1" 223 224 require_command cargo 225 226 ./scripts/check-dependencies.sh 227 cargo test --locked 228 cargo check --locked --manifest-path fuzz/Cargo.toml 229 ./e2e/iuna_e2e.py test snapshots 230 231 if [ "$skip_long_tests" = "true" ]; then 232 echo "WARNING: skipping long-running adversarial, fuzz, post-activation E2E, and property test suites" 233 return 0 234 fi 235 236 local fuzz_seconds="${IUNA_FUZZ_SECONDS:-60}" 237 local vdf_fuzz_seconds="${IUNA_VDF_FUZZ_SECONDS:-15}" 238 validate_positive_integer IUNA_FUZZ_SECONDS "$fuzz_seconds" 239 validate_positive_integer IUNA_VDF_FUZZ_SECONDS "$vdf_fuzz_seconds" 240 require_cargo_fuzz 241 242 local release_evidence_dir="${IUNA_RELEASE_EVIDENCE_DIR:-release-evidence}" 243 local fuzz_evidence_dir="${release_evidence_dir}/fuzz" 244 245 cargo test --locked --release --lib domain::adversarial_tests:: -- --ignored 246 run_fuzz_campaign p2p_envelope "$fuzz_seconds" "$fuzz_evidence_dir" 247 run_fuzz_campaign compact_snapshot "$fuzz_seconds" "$fuzz_evidence_dir" 248 run_fuzz_campaign domain_json "$fuzz_seconds" "$fuzz_evidence_dir" 249 run_fuzz_campaign stratum_request "$fuzz_seconds" "$fuzz_evidence_dir" 250 run_fuzz_campaign wallet_config "$fuzz_seconds" "$fuzz_evidence_dir" 251 run_fuzz_campaign vdf_proof "$vdf_fuzz_seconds" "$fuzz_evidence_dir" 252 run_fuzz_campaign transaction_v2 "$fuzz_seconds" "$fuzz_evidence_dir" 253 cargo test --locked --release --features e2e --test properties -- --ignored 254 ./e2e/iuna_e2e.py test post-activation --build --evidence-dir "$release_evidence_dir" 255 } 256 257 update_versions() { 258 local version="$1" 259 260 require_command cargo 261 require_command perl 262 263 replace_in_file Cargo.toml '(\[package\]\nname = "iuna"\nversion = ")[^"]+' "\${1}${version}" 264 replace_in_file src-tauri/Cargo.toml '(\[package\]\nname = "iuna-desktop"\nversion = ")[^"]+' "\${1}${version}" 265 replace_in_file src-tauri/tauri.conf.json '("version": ")[^"]+' "\${1}${version}" 266 replace_in_file README.md 'downloads/iuna-v[0-9]+\.[0-9]+\.[0-9]+-macos-aarch64-desktop\.app\.zip' "downloads/iuna-v${version}-macos-aarch64-desktop.app.zip" 267 replace_in_file README.md 'downloads/iuna-v[0-9]+\.[0-9]+\.[0-9]+-windows-x86_64-desktop-setup\.exe' "downloads/iuna-v${version}-windows-x86_64-desktop-setup.exe" 268 269 cargo update --offline -p iuna --precise "$version" 270 cargo update --offline --manifest-path src-tauri/Cargo.toml -p iuna-desktop --precise "$version" 271 cargo update --offline --manifest-path fuzz/Cargo.toml -p iuna --precise "$version" 272 cargo check --locked >/dev/null 273 cargo check --locked --manifest-path src-tauri/Cargo.toml >/dev/null 274 cargo check --locked --manifest-path fuzz/Cargo.toml >/dev/null 275 } 276 277 write_changelog_section() { 278 local version="$1" 279 local release_date="$2" 280 local range="$3" 281 local enforce_titles="$4" 282 local output="$5" 283 local subject 284 local prefix 285 local type 286 local description 287 local category 288 local fragments_dir 289 local commit_count=0 290 local conventional_pattern='^(feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9][a-z0-9._/-]*\))?!?: .+' 291 292 fragments_dir="$(mktemp -d)" 293 294 while IFS= read -r subject; do 295 [ -n "$subject" ] || continue 296 297 case "$subject" in 298 "Release v${version}"|"Release ${version}"|"Bump version to ${version}"|\ 299 "Prepare v${version}"|"v${version}"|"iuna v${version}"|\ 300 "chore(release): release v${version}") 301 continue 302 ;; 303 esac 304 305 if [ "$enforce_titles" = "true" ]; then 306 ./scripts/check-commit-title.sh --title "$subject" || \ 307 die "release commits must use Conventional Commit titles" 308 fi 309 310 if [[ "$subject" =~ $conventional_pattern ]]; then 311 prefix="${subject%%:*}" 312 type="${prefix%%\(*}" 313 type="${type%%!*}" 314 description="${subject#*: }" 315 if [[ "$prefix" == *! ]]; then 316 description="**Breaking:** ${description}" 317 fi 318 319 case "$type" in 320 feat) category=added ;; 321 fix) category=fixed ;; 322 perf) category=performance ;; 323 refactor) category=changed ;; 324 docs) category=documentation ;; 325 test) category=tests ;; 326 build) category=build ;; 327 ci) category=ci ;; 328 chore) category=maintenance ;; 329 revert) category=reverted ;; 330 *) die "unsupported commit type in title: ${subject}" ;; 331 esac 332 else 333 category=changed 334 description="$subject" 335 fi 336 337 printf -- '- %s\n' "$description" >> "${fragments_dir}/${category}" 338 commit_count=$((commit_count + 1)) 339 done < <(git log --reverse --no-merges --format='%s' "$range") 340 341 { 342 printf '## [%s] - %s\n' "$version" "$release_date" 343 if [ "$commit_count" -eq 0 ]; then 344 printf '\n### Changed\n\n- No notable changes recorded.\n' 345 fi 346 while IFS='|' read -r category heading; do 347 [ -s "${fragments_dir}/${category}" ] || continue 348 printf '\n### %s\n\n' "$heading" 349 cat "${fragments_dir}/${category}" 350 done <<'EOF' 351 added|Added 352 fixed|Fixed 353 changed|Changed 354 performance|Performance 355 documentation|Documentation 356 tests|Tests 357 build|Build 358 ci|Continuous integration 359 maintenance|Maintenance 360 reverted|Reverted 361 EOF 362 printf '\n' 363 } >> "$output" 364 365 rm -rf "$fragments_dir" 366 changelog_section_commit_count="$commit_count" 367 } 368 369 generate_changelog() { 370 local version="${1:-}" 371 local latest_tag 372 local range 373 local changelog_file 374 local tag 375 local previous_tag 376 local release_date 377 local i 378 local section_count=0 379 local tags=() 380 381 require_command git 382 require_command perl 383 384 changelog_file="$(mktemp)" 385 386 { 387 printf '# Changelog\n\n' 388 printf 'All notable changes to iuna are documented in this file. Releases are generated\n' 389 printf 'from the Git history and Conventional Commit titles by `deployment.sh`.\n\n' 390 printf '## [Unreleased]\n\n' 391 } > "$changelog_file" 392 393 latest_tag="$(git describe --tags --abbrev=0 2>/dev/null || true)" 394 if [ -n "$version" ]; then 395 if [ -n "$latest_tag" ]; then 396 range="${latest_tag}..HEAD" 397 else 398 range="HEAD" 399 fi 400 write_changelog_section "$version" "$(date +%Y-%m-%d)" "$range" true "$changelog_file" 401 [ "$changelog_section_commit_count" -gt 0 ] || \ 402 die "no commits found for changelog since ${latest_tag:-the start of the repository}" 403 section_count=$((section_count + 1)) 404 fi 405 406 while IFS= read -r tag; do 407 [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue 408 tags+=("$tag") 409 done < <(git tag --list 'v*' --sort=v:refname) 410 411 i=$((${#tags[@]} - 1)) 412 while [ "$i" -ge 0 ]; do 413 tag="${tags[$i]}" 414 if [ "$i" -gt 0 ]; then 415 previous_tag="${tags[$((i - 1))]}" 416 range="${previous_tag}..${tag}" 417 else 418 range="${tag}^{commit}" 419 fi 420 release_date="$(git for-each-ref --format='%(creatordate:short)' "refs/tags/${tag}")" 421 [ -n "$release_date" ] || release_date="$(git log -1 --format='%cs' "${tag}^{commit}")" 422 write_changelog_section "${tag#v}" "$release_date" "$range" false "$changelog_file" 423 section_count=$((section_count + 1)) 424 i=$((i - 1)) 425 done 426 427 perl -0pi -e 's/\n+\z/\n/' "$changelog_file" 428 chmod 644 "$changelog_file" 429 mv "$changelog_file" CHANGELOG.md 430 echo "Generated CHANGELOG.md with ${section_count} release section(s)" 431 } 432 433 commit_and_tag() { 434 local version="$1" 435 local tag="v${version}" 436 437 require_command git 438 439 git add "${RELEASE_VERSION_FILES[@]}" 440 git commit -m "chore(release): release ${tag}" 441 git tag -a "$tag" -m "Release ${tag}" 442 } 443 444 build_macos_desktop_if_possible() { 445 local version="$1" 446 local artifact="downloads/iuna-v${version}-macos-aarch64-desktop.app.zip" 447 448 [ -f "$artifact" ] \ 449 && [ -f "downloads/iuna-v${version}-macos-aarch64-desktop-update.app.tar.gz" ] \ 450 && [ -f "downloads/iuna-v${version}-macos-aarch64-desktop-update.app.tar.gz.sig" ] \ 451 && return 0 452 [ "$(uname -s)" = "Darwin" ] || return 0 453 is_apple_silicon_macos || die "macOS desktop artifact requires Apple silicon; expected ${artifact}" 454 455 require_command codesign 456 require_command ditto 457 require_command rustup 458 ensure_tauri_cli 459 local signing_key 460 signing_key="$(update_signing_key)" 461 rustup target add aarch64-apple-darwin 462 cargo build --release --locked --target aarch64-apple-darwin 463 mkdir -p src-tauri/binaries downloads 464 cp target/aarch64-apple-darwin/release/iuna src-tauri/binaries/iuna-sidecar-aarch64-apple-darwin 465 chmod +x src-tauri/binaries/iuna-sidecar-aarch64-apple-darwin 466 (cd src-tauri && \ 467 TAURI_SIGNING_PRIVATE_KEY="$(cat "$signing_key")" \ 468 TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD-}" \ 469 cargo tauri build --target aarch64-apple-darwin --bundles app) 470 471 local app="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/iuna.app" 472 local updater_archive="${app}.tar.gz" 473 codesign --verify --deep --strict --verbose=4 "$app" 474 [ -f "$updater_archive" ] || die "missing macOS updater archive: ${updater_archive}" 475 [ -f "${updater_archive}.sig" ] || die "missing macOS updater signature: ${updater_archive}.sig" 476 ditto -c -k --keepParent "$app" "$artifact" 477 cp "$updater_archive" "downloads/iuna-v${version}-macos-aarch64-desktop-update.app.tar.gz" 478 cp "${updater_archive}.sig" "downloads/iuna-v${version}-macos-aarch64-desktop-update.app.tar.gz.sig" 479 } 480 481 build_windows_desktop_if_possible() { 482 local version="$1" 483 local artifact="downloads/iuna-v${version}-windows-x86_64-desktop-setup.exe" 484 485 [ -f "$artifact" ] && [ -f "${artifact}.sig" ] && return 0 486 case "$(uname -s)" in 487 MINGW*|MSYS*|CYGWIN*) ;; 488 *) return 0 ;; 489 esac 490 491 ensure_tauri_cli 492 local signing_key 493 signing_key="$(update_signing_key)" 494 cargo build --release --locked 495 mkdir -p src-tauri/binaries downloads 496 cp target/release/iuna.exe src-tauri/binaries/iuna-sidecar-x86_64-pc-windows-msvc.exe 497 local nsis_dir="src-tauri/target/release/bundle/nsis" 498 clear_nsis_installers "$nsis_dir" 499 (cd src-tauri && \ 500 TAURI_SIGNING_PRIVATE_KEY="$(cat "$signing_key")" \ 501 TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD-}" \ 502 cargo tauri build --bundles nsis) 503 504 local installer 505 installer="$(versioned_nsis_installer "$nsis_dir" "$version")" 506 cp "$installer" "$artifact" 507 [ -f "${installer}.sig" ] || die "missing Windows updater signature: ${installer}.sig" 508 cp "${installer}.sig" "${artifact}.sig" 509 } 510 511 build_windows_desktop_in_docker_if_possible() { 512 local version="$1" 513 local artifact="downloads/iuna-v${version}-windows-x86_64-desktop-setup.exe" 514 local builder_platform 515 local builder_arch 516 local signing_key 517 518 [ -f "$artifact" ] && [ -f "${artifact}.sig" ] && return 0 519 command -v docker >/dev/null 2>&1 || return 0 520 521 builder_platform="$(docker_native_linux_platform)" 522 builder_arch="${builder_platform#linux/}" 523 signing_key="$(update_signing_key)" 524 525 mkdir -p downloads 526 docker run --rm --pull=always --platform="$builder_platform" \ 527 -e "IUNA_VERSION=${version}" \ 528 -e "HOST_UID=$(id -u)" \ 529 -e "HOST_GID=$(id -g)" \ 530 -e "TAURI_CLI_VERSION=${TAURI_CLI_VERSION}" \ 531 -e "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${TAURI_SIGNING_PRIVATE_KEY_PASSWORD-}" \ 532 -v iuna-windows-cargo-registry:/usr/local/cargo/registry \ 533 -v iuna-windows-cargo-git:/usr/local/cargo/git \ 534 -v iuna-windows-root-cache:/root/.cache \ 535 -v "iuna-windows-${builder_arch}-target:/work/iuna/target" \ 536 -v "iuna-windows-${builder_arch}-tauri-target:/work/iuna/src-tauri/target" \ 537 -v "$(pwd):/src/iuna:ro" \ 538 -v "$(pwd)/downloads:/out" \ 539 -v "${signing_key}:/run/secrets/iuna-update.key:ro" \ 540 rust:1.88-bookworm \ 541 bash -c ' 542 set -euo pipefail 543 544 export TAURI_SIGNING_PRIVATE_KEY="$(cat /run/secrets/iuna-update.key)" 545 546 apt-get update 547 # The Linux-hosted Tauri CLI inspects enabled tray features while preparing 548 # bundle settings, even when cargo-xwin targets a Windows NSIS installer. 549 apt-get install -y --no-install-recommends \ 550 clang \ 551 libayatana-appindicator3-dev \ 552 lld \ 553 llvm \ 554 nsis \ 555 pkg-config 556 rm -rf /var/lib/apt/lists/* 557 rustup target add x86_64-pc-windows-msvc 558 cargo install --locked cargo-xwin --version 0.19.2 559 cargo install --locked tauri-cli --version "=${TAURI_CLI_VERSION}" 560 561 nsis_utils_path=/root/.cache/tauri/NSIS/Plugins/x86-unicode/additional/nsis_tauri_utils.dll 562 mkdir -p "$(dirname "$nsis_utils_path")" 563 if [ ! -f "$nsis_utils_path" ]; then 564 curl --fail --location --retry 8 --retry-all-errors --retry-delay 3 \ 565 --output "$nsis_utils_path" \ 566 https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll 567 echo "75197fee3c6a814fe035788d1c34ead39349b860 $nsis_utils_path" | sha1sum -c - 568 fi 569 570 mkdir -p /work/iuna 571 tar -C /src/iuna \ 572 --exclude=./target \ 573 --exclude=./src-tauri/target \ 574 --exclude=./src-tauri/binaries \ 575 --exclude=./.agents \ 576 --exclude=./.codex \ 577 -cf - . | tar -C /work/iuna -xf - 578 579 cd /work/iuna 580 cargo xwin build --release --locked --target x86_64-pc-windows-msvc 581 mkdir -p src-tauri/binaries 582 cp target/x86_64-pc-windows-msvc/release/iuna.exe src-tauri/binaries/iuna-sidecar-x86_64-pc-windows-msvc.exe 583 584 cd src-tauri 585 nsis_dir=target/x86_64-pc-windows-msvc/release/bundle/nsis 586 mkdir -p "$nsis_dir" 587 find "$nsis_dir" -maxdepth 1 -type f -name "*-setup.exe" -delete 588 cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis 589 590 installer="${nsis_dir}/iuna_${IUNA_VERSION}_x64-setup.exe" 591 [ -f "$installer" ] || { echo "Windows installer for version ${IUNA_VERSION} was not produced at ${installer}" >&2; exit 1; } 592 cp "$installer" "/out/iuna-v${IUNA_VERSION}-windows-x86_64-desktop-setup.exe" 593 test -f "${installer}.sig" || { echo "missing Windows updater signature: ${installer}.sig" >&2; exit 1; } 594 cp "${installer}.sig" "/out/iuna-v${IUNA_VERSION}-windows-x86_64-desktop-setup.exe.sig" 595 chown "${HOST_UID}:${HOST_GID}" "/out/iuna-v${IUNA_VERSION}-windows-x86_64-desktop-setup.exe" "/out/iuna-v${IUNA_VERSION}-windows-x86_64-desktop-setup.exe.sig" 596 ' 597 } 598 599 require_desktop_artifacts() { 600 local version="$1" 601 local macos_artifact="downloads/iuna-v${version}-macos-aarch64-desktop.app.zip" 602 local windows_artifact="downloads/iuna-v${version}-windows-x86_64-desktop-setup.exe" 603 604 [ -f "$macos_artifact" ] || die "missing ${macos_artifact}" 605 [ -f "$windows_artifact" ] || die "missing ${windows_artifact}" 606 } 607 608 build_linux_cli_archives() { 609 local version="$1" 610 local tag="v${version}" 611 local linux_x86_64_package="iuna-${tag}-linux-x86_64" 612 local linux_aarch64_package="iuna-${tag}-linux-aarch64" 613 local builder_platform 614 615 mkdir -p .docker-build downloads 616 [ -f "downloads/${linux_x86_64_package}.tar.gz" ] \ 617 && [ -f "downloads/${linux_aarch64_package}.tar.gz" ] \ 618 && [ -f .docker-build/iuna-node-linux-x86_64 ] \ 619 && return 0 620 621 require_command docker 622 builder_platform="$(docker_native_linux_platform)" 623 624 docker run --rm --pull=always --platform="$builder_platform" \ 625 -e "IUNA_VERSION=${version}" \ 626 -e "HOST_UID=$(id -u)" \ 627 -e "HOST_GID=$(id -g)" \ 628 -v "$(pwd):/src/iuna:ro" \ 629 -v "$(pwd)/downloads:/out" \ 630 -v "$(pwd)/.docker-build:/node-out" \ 631 rust:1.88-bookworm \ 632 bash -c ' 633 set -euo pipefail 634 635 apt-get update 636 case "$(uname -m)" in 637 x86_64) 638 apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libc6-dev-arm64-cross 639 ;; 640 aarch64|arm64) 641 apt-get install -y --no-install-recommends gcc-x86-64-linux-gnu libc6-dev-amd64-cross 642 ;; 643 *) 644 echo "unsupported Linux builder architecture: $(uname -m)" >&2 645 exit 1 646 ;; 647 esac 648 rm -rf /var/lib/apt/lists/* 649 rustup target add aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu 650 651 mkdir -p /work/iuna 652 tar -C /src/iuna \ 653 --exclude=./target \ 654 --exclude=./src-tauri/target \ 655 --exclude=./src-tauri/binaries \ 656 --exclude=./.agents \ 657 --exclude=./.codex \ 658 --exclude=./.docker-build \ 659 -cf - . | tar -C /work/iuna -xf - 660 661 cd /work/iuna 662 CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ 663 AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \ 664 CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ 665 cargo build --release --locked --target aarch64-unknown-linux-gnu 666 CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \ 667 AR_x86_64_unknown_linux_gnu=x86_64-linux-gnu-ar \ 668 CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \ 669 cargo build --release --locked --target x86_64-unknown-linux-gnu 670 671 tag="v${IUNA_VERSION}" 672 linux_x86_64_package="iuna-${tag}-linux-x86_64" 673 linux_aarch64_package="iuna-${tag}-linux-aarch64" 674 mkdir -p "/tmp/site/${linux_x86_64_package}" "/tmp/site/${linux_aarch64_package}" 675 cp target/x86_64-unknown-linux-gnu/release/iuna "/tmp/site/${linux_x86_64_package}/" 676 cp target/aarch64-unknown-linux-gnu/release/iuna "/tmp/site/${linux_aarch64_package}/" 677 cp target/x86_64-unknown-linux-gnu/release/iuna /node-out/iuna-node-linux-x86_64 678 cp README.md LICENSE "/tmp/site/${linux_x86_64_package}/" 679 cp README.md LICENSE "/tmp/site/${linux_aarch64_package}/" 680 tar -C /tmp/site -czf "/out/${linux_x86_64_package}.tar.gz" "${linux_x86_64_package}" 681 tar -C /tmp/site -czf "/out/${linux_aarch64_package}.tar.gz" "${linux_aarch64_package}" 682 chown "${HOST_UID}:${HOST_GID}" "/out/${linux_x86_64_package}.tar.gz" "/out/${linux_aarch64_package}.tar.gz" /node-out/iuna-node-linux-x86_64 683 ' 684 } 685 686 sign_cli_archives() { 687 local version="$1" 688 local signing_key 689 local artifact 690 691 ensure_tauri_cli 692 signing_key="$(update_signing_key)" 693 for artifact in \ 694 "downloads/iuna-v${version}-linux-x86_64.tar.gz" \ 695 "downloads/iuna-v${version}-linux-aarch64.tar.gz"; do 696 [ -f "$artifact" ] || die "missing CLI update artifact: ${artifact}" 697 cargo tauri signer sign -f "$signing_key" -p "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD-}" "$artifact" 698 done 699 } 700 701 file_sha256() { 702 local file="$1" 703 if command -v sha256sum >/dev/null 2>&1; then 704 sha256sum "$file" | awk '{print $1}' 705 else 706 shasum -a 256 "$file" | awk '{print $1}' 707 fi 708 } 709 710 write_release_metadata() { 711 local version="$1" 712 local base="https://getiuna.org/downloads" 713 local linux_x86="iuna-v${version}-linux-x86_64.tar.gz" 714 local linux_arm="iuna-v${version}-linux-aarch64.tar.gz" 715 local mac="iuna-v${version}-macos-aarch64-desktop-update.app.tar.gz" 716 local windows="iuna-v${version}-windows-x86_64-desktop-setup.exe" 717 718 require_command jq 719 for file in "$linux_x86" "$linux_arm" "$mac" "$windows"; do 720 [ -f "downloads/$file" ] || die "missing release artifact: downloads/${file}" 721 [ -f "downloads/${file}.sig" ] || die "missing release signature: downloads/${file}.sig" 722 done 723 724 jq -n \ 725 --arg tag "v${version}" \ 726 --arg version "$version" \ 727 --arg url "${base}/" \ 728 --arg linux_x86_url "${base}/${linux_x86}" \ 729 --arg linux_x86_sha "$(file_sha256 "downloads/$linux_x86")" \ 730 --rawfile linux_x86_sig "downloads/${linux_x86}.sig" \ 731 --arg linux_arm_url "${base}/${linux_arm}" \ 732 --arg linux_arm_sha "$(file_sha256 "downloads/$linux_arm")" \ 733 --rawfile linux_arm_sig "downloads/${linux_arm}.sig" \ 734 '{tag: $tag, version: $version, url: $url, artifacts: { 735 "linux-x86_64": {url: $linux_x86_url, sha256: $linux_x86_sha, signature: $linux_x86_sig}, 736 "linux-aarch64": {url: $linux_arm_url, sha256: $linux_arm_sha, signature: $linux_arm_sig} 737 }}' > downloads/latest.json 738 739 mkdir -p downloads/desktop 740 jq -n \ 741 --arg version "$version" \ 742 --arg mac_url "${base}/${mac}" \ 743 --rawfile mac_sig "downloads/${mac}.sig" \ 744 --arg windows_url "${base}/${windows}" \ 745 --rawfile windows_sig "downloads/${windows}.sig" \ 746 '{version: $version, platforms: { 747 "darwin-aarch64": {url: $mac_url, signature: $mac_sig}, 748 "windows-x86_64": {url: $windows_url, signature: $windows_sig} 749 }}' > downloads/desktop/latest.json 750 } 751 752 write_download_checksums() { 753 ( 754 cd downloads 755 rm -f SHA256SUMS 756 757 local files=() 758 local file 759 for file in *; do 760 [ -f "$file" ] || continue 761 case "$file" in 762 .gitkeep|index.html|SHA256SUMS) continue ;; 763 esac 764 files+=("$file") 765 done 766 767 [ "${#files[@]}" -gt 0 ] || return 0 768 if command -v sha256sum >/dev/null 2>&1; then 769 sha256sum "${files[@]}" > SHA256SUMS 770 else 771 for file in "${files[@]}"; do 772 shasum -a 256 "$file" | awk "{print \$1 \" \" \$2}" 773 done > SHA256SUMS 774 fi 775 ) 776 } 777 778 build_versions() { 779 local version="$1" 780 781 mkdir -p downloads 782 validate_update_public_key 783 build_linux_cli_archives "$version" 784 build_macos_desktop_if_possible "$version" 785 build_windows_desktop_if_possible "$version" 786 build_windows_desktop_in_docker_if_possible "$version" 787 require_desktop_artifacts "$version" 788 sign_cli_archives "$version" 789 write_release_metadata "$version" 790 write_download_checksums 791 } 792 793 build_docker_image() { 794 local version="$1" 795 local www_image="${IUNA_WWW_IMAGE:-iuna-www:v${version}}" 796 local node_image="${IUNA_NODE_IMAGE:-iuna-node:v${version}}" 797 798 require_command docker 799 800 [ -f .docker-build/iuna-node-linux-x86_64 ] || die "missing .docker-build/iuna-node-linux-x86_64; run build_versions first" 801 802 docker build --platform=linux/amd64 --progress=plain -t "$www_image" . 803 docker build --platform=linux/amd64 --progress=plain -t "$node_image" -f Dockerfile.node . 804 echo "Built Docker images: ${www_image}, ${node_image}" 805 } 806 807 website_image_for_head() { 808 local commit 809 810 require_command git 811 commit="$(git rev-parse --short=12 HEAD)" 812 printf '%s\n' "${IUNA_WWW_IMAGE:-iuna-www:git-${commit}}" 813 } 814 815 build_website_image() { 816 local image="$1" 817 818 require_command docker 819 docker build --platform=linux/amd64 --progress=plain -t "$image" . 820 echo "Built website image: ${image}" 821 } 822 823 import_image_to_k3s() { 824 local image="$1" 825 local tmp_folder="$2" 826 local remote_host="${IUNA_DEPLOY_HOST:-root@jhx.app}" 827 local remote_file="${image//[:\/]/_}.tar" 828 local image_file="${tmp_folder}/${remote_file}" 829 830 require_command docker 831 require_command scp 832 require_command ssh 833 834 docker save "$image" -o "$image_file" 835 scp "$image_file" "${remote_host}:~/" 836 ssh "$remote_host" "sudo k3s ctr -n k8s.io images import ~/${remote_file} && rm ~/${remote_file}" 837 } 838 839 deploy_website_image() { 840 local image="$1" 841 local kubectl_context="${IUNA_KUBECTL_CONTEXT:-jhx-app}" 842 local tmp_folder 843 844 require_command kubectl 845 846 tmp_folder="$(mktemp -d)" 847 trap 'rm -rf "$tmp_folder"' RETURN 848 849 import_image_to_k3s "$image" "$tmp_folder" 850 kubectl --context "$kubectl_context" -n iuna set image deployment/www "iuna-www=${image}" 851 kubectl --context "$kubectl_context" -n iuna rollout restart deployment/www 852 kubectl --context "$kubectl_context" -n iuna rollout status deployment/www 853 } 854 855 render_manifest() { 856 local www_image="$1" 857 local node_image="$2" 858 local node_pvc="$3" 859 local genesis="$4" 860 local output="$5" 861 local local_allowlist_file="config/admin-ip-allowlist.local" 862 local allowlist_entry 863 local allowlist_entry_count=0 864 local escaped_www_image 865 local escaped_node_image 866 local escaped_node_pvc 867 868 escaped_www_image="$(escape_sed_replacement "$www_image")" 869 escaped_node_image="$(escape_sed_replacement "$node_image")" 870 escaped_node_pvc="$(escape_sed_replacement "$node_pvc")" 871 872 [ -f "$local_allowlist_file" ] || die "missing local admin allowlist: ${local_allowlist_file}" 873 while IFS= read -r allowlist_entry || [ -n "$allowlist_entry" ]; do 874 allowlist_entry="${allowlist_entry%%#*}" 875 allowlist_entry="${allowlist_entry//[[:space:]]/}" 876 [ -z "$allowlist_entry" ] && continue 877 [[ "$allowlist_entry" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/(3[0-2]|[12]?[0-9])$ ]] || \ 878 die "invalid CIDR in ${local_allowlist_file}: ${allowlist_entry}" 879 allowlist_entry_count=$((allowlist_entry_count + 1)) 880 done < "$local_allowlist_file" 881 [ "$allowlist_entry_count" -gt 0 ] || die "local admin allowlist is empty: ${local_allowlist_file}" 882 883 sed \ 884 -e "s|\${IUNA_WWW_IMAGE}|${escaped_www_image}|g" \ 885 -e "s|\${IUNA_NODE_IMAGE}|${escaped_node_image}|g" \ 886 -e "s|\${IUNA_NODE_PVC}|${escaped_node_pvc}|g" \ 887 config/deployment.yml | awk \ 888 -v local_allowlist_file="$local_allowlist_file" \ 889 -v genesis="$genesis" ' 890 $0 == "${IUNA_NODE_GENESIS_ARG}" { 891 if (genesis == "true") print " - --genesis" 892 next 893 } 894 $0 == "${IUNA_ADMIN_IP_ALLOWLIST_LOCAL}" { 895 while ((getline entry < local_allowlist_file) > 0) { 896 sub(/#.*/, "", entry) 897 gsub(/[[:space:]]/, "", entry) 898 if (entry != "") print " - " entry 899 } 900 close(local_allowlist_file) 901 next 902 } 903 { print } 904 ' > "$output" 905 } 906 907 deploy_docker_image() { 908 local version="$1" 909 local genesis="$2" 910 local www_image="${IUNA_WWW_IMAGE:-iuna-www:v${version}}" 911 local node_image="${IUNA_NODE_IMAGE:-iuna-node:v${version}}" 912 local node_pvc="local-path-db-pvc" 913 local kubectl_context="${IUNA_KUBECTL_CONTEXT:-jhx-app}" 914 local tmp_folder 915 916 require_command kubectl 917 918 tmp_folder="$(mktemp -d)" 919 trap 'rm -rf "$tmp_folder"' RETURN 920 921 import_image_to_k3s "$www_image" "$tmp_folder" 922 import_image_to_k3s "$node_image" "$tmp_folder" 923 render_manifest "$www_image" "$node_image" "$node_pvc" "$genesis" "${tmp_folder}/deployment.yml" 924 kubectl --context "$kubectl_context" apply -f config/traefik.yml 925 926 if [ "$genesis" = "true" ]; then 927 echo "WARNING: the existing chain is about to be permanently deleted." 928 echo "Kubernetes context: ${kubectl_context}" 929 echo "Namespace: iuna" 930 echo "PVC: ${node_pvc}" 931 if ! confirm "Are you absolutely sure? (Y/N) "; then 932 echo "Deployment aborted; the existing node and PVC were not deleted" 933 exit 1 934 fi 935 echo "Deleting the existing node and PVC ${node_pvc}" 936 kubectl --context "$kubectl_context" -n iuna delete deployment node --ignore-not-found --wait=true 937 kubectl --context "$kubectl_context" -n iuna delete pvc "$node_pvc" --ignore-not-found --wait=true 938 fi 939 940 local current_www_selector 941 current_www_selector="$(kubectl --context "$kubectl_context" -n iuna get deployment www -o jsonpath='{.spec.selector.matchLabels.app}' 2>/dev/null || true)" 942 if [ -n "$current_www_selector" ] && [ "$current_www_selector" != "iuna-www" ]; then 943 kubectl --context "$kubectl_context" -n iuna delete deployment www --wait=true 944 fi 945 946 kubectl --context "$kubectl_context" apply -f "${tmp_folder}/deployment.yml" 947 kubectl --context "$kubectl_context" -n iuna rollout restart deployment/www deployment/node 948 kubectl --context "$kubectl_context" -n iuna rollout status deployment/www 949 kubectl --context "$kubectl_context" -n iuna rollout status deployment/node 950 951 if [ "$genesis" = "true" ]; then 952 echo "Genesis started successfully; removing --genesis for subsequent pod starts" 953 render_manifest "$www_image" "$node_image" "$node_pvc" false "${tmp_folder}/deployment.yml" 954 kubectl --context "$kubectl_context" apply -f "${tmp_folder}/deployment.yml" 955 kubectl --context "$kubectl_context" -n iuna rollout status deployment/node 956 fi 957 } 958 959 main() { 960 local genesis=false 961 local skip_long_tests=false 962 local website_only=false 963 local version="" 964 965 while [ "$#" -gt 0 ]; do 966 case "$1" in 967 --genesis) 968 [ "$genesis" = "false" ] || die "--genesis may only be specified once" 969 genesis=true 970 ;; 971 --skip-long-tests) 972 [ "$skip_long_tests" = "false" ] || die "--skip-long-tests may only be specified once" 973 skip_long_tests=true 974 ;; 975 --website-only) 976 [ "$website_only" = "false" ] || die "--website-only may only be specified once" 977 website_only=true 978 ;; 979 -h|--help) 980 usage 981 exit 0 982 ;; 983 -*) 984 die "unknown option: $1" 985 ;; 986 *) 987 [ -z "$version" ] || { usage >&2; exit 2; } 988 version="${1#v}" 989 ;; 990 esac 991 shift 992 done 993 994 if [ "$website_only" = "true" ]; then 995 [ -z "$version" ] || die "--website-only does not accept a version" 996 [ "$genesis" = "false" ] || die "--website-only cannot be combined with --genesis" 997 [ "$skip_long_tests" = "false" ] || die "--website-only cannot be combined with --skip-long-tests" 998 999 ensure_clean_worktree 1000 1001 local website_image 1002 website_image="$(website_image_for_head)" 1003 echo "Website-only deployment from commit $(git rev-parse --short HEAD)" 1004 echo "Image: ${website_image}" 1005 if ! confirm "Are you sure you want to deploy the website? (y/N) "; then 1006 echo "Aborting deployment" 1007 exit 1 1008 fi 1009 build_website_image "$website_image" 1010 deploy_website_image "$website_image" 1011 exit 0 1012 fi 1013 1014 [ -n "$version" ] || { usage >&2; exit 2; } 1015 [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "version must look like 0.2.48" 1016 1017 ensure_clean_worktree 1018 1019 if [ "$genesis" = "true" ]; then 1020 echo "WARNING: this starts a new chain with --genesis and a fresh PVC." 1021 echo "The existing chain in PVC local-path-db-pvc will be PERMANENTLY DELETED." 1022 echo "An empty PVC will then be created with the same permanent name." 1023 if ! confirm "Are you sure? (Y/N) "; then 1024 echo "Deployment aborted" 1025 exit 1 1026 fi 1027 fi 1028 1029 # Check if the tag already exists; if it does, only deploy 1030 if git rev-parse --verify "v${version}" >/dev/null 2>&1; then 1031 ensure_head_matches_tag "v${version}" 1032 echo "Tag v${version} already exists; rebuilding Docker images and deploying" 1033 if [ "$genesis" != "true" ] && ! confirm "Are you sure you want to deploy v${version}? (y/N) "; then 1034 echo "Aborting deployment" 1035 exit 1 1036 fi 1037 run_release_tests "$skip_long_tests" 1038 build_versions "$version" 1039 build_docker_image "$version" 1040 deploy_docker_image "$version" "$genesis" 1041 exit 0 1042 fi 1043 1044 arm_release_rollback 1045 generate_changelog "$version" 1046 update_versions "$version" 1047 run_release_tests "$skip_long_tests" 1048 build_versions "$version" 1049 commit_and_tag "$version" 1050 disarm_release_rollback 1051 build_docker_image "$version" 1052 deploy_docker_image "$version" "$genesis" 1053 } 1054 1055 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then 1056 main "$@" 1057 fi