deployment.sh (14407B)
1 #!/usr/bin/env bash 2 set -euo pipefail 3 4 cd "$(dirname "${BASH_SOURCE[0]}")" 5 6 usage() { 7 echo "Usage: $0 <version>" >&2 8 echo "Example: $0 0.2.48" >&2 9 } 10 11 die() { 12 echo "error: $*" >&2 13 exit 1 14 } 15 16 require_command() { 17 local command_name="$1" 18 19 command -v "$command_name" >/dev/null 2>&1 || die "missing required command: ${command_name}" 20 } 21 22 is_apple_silicon_macos() { 23 [ "$(uname -s)" = "Darwin" ] || return 1 24 [ "$(uname -m)" = "arm64" ] && return 0 25 [ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" = "1" ] 26 } 27 28 confirm() { 29 local prompt="$1" 30 local answer 31 32 if ! read -r -p "$prompt" answer || [[ ! "$answer" =~ ^[Yy]$ ]]; then 33 return 1 34 fi 35 } 36 37 escape_sed_replacement() { 38 printf '%s' "$1" | sed -e 's/[\/&]/\\&/g' 39 } 40 41 replace_in_file() { 42 local file="$1" 43 local pattern="$2" 44 local replacement="$3" 45 perl -0pi -e "s|${pattern}|${replacement}|g" "$file" 46 } 47 48 ensure_clean_worktree() { 49 require_command git 50 51 if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then 52 die "worktree is not clean; commit or stash changes before releasing" 53 fi 54 } 55 56 ensure_head_matches_tag() { 57 local tag="$1" 58 local head_commit 59 local tag_commit 60 61 head_commit="$(git rev-parse HEAD)" 62 tag_commit="$(git rev-parse "${tag}^{commit}")" 63 [ "$head_commit" = "$tag_commit" ] || die "${tag} exists, but HEAD is not at ${tag}; checkout ${tag} before redeploying it" 64 } 65 66 ensure_tauri_cli() { 67 require_command cargo 68 69 if ! cargo tauri --version >/dev/null 2>&1; then 70 cargo install tauri-cli --locked --version "^2" 71 fi 72 } 73 74 update_versions() { 75 local version="$1" 76 77 require_command cargo 78 require_command perl 79 80 replace_in_file Cargo.toml '(\[package\]\nname = "iuna"\nversion = ")[^"]+' "\${1}${version}" 81 replace_in_file src-tauri/Cargo.toml '(\[package\]\nname = "iuna-desktop"\nversion = ")[^"]+' "\${1}${version}" 82 replace_in_file src-tauri/tauri.conf.json '("version": ")[^"]+' "\${1}${version}" 83 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" 84 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" 85 86 cargo update -p iuna --precise "$version" 87 cargo update --manifest-path src-tauri/Cargo.toml -p iuna-desktop --precise "$version" 88 cargo check --locked >/dev/null 89 cargo check --locked --manifest-path src-tauri/Cargo.toml >/dev/null 90 } 91 92 commit_and_tag() { 93 local version="$1" 94 local tag="v${version}" 95 96 require_command git 97 98 git add Cargo.toml Cargo.lock src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json README.md 99 git commit -m "Release ${tag}" 100 git tag -a "$tag" -m "Release ${tag}" 101 } 102 103 build_macos_desktop_if_possible() { 104 local version="$1" 105 local artifact="downloads/iuna-v${version}-macos-aarch64-desktop.app.zip" 106 107 [ -f "$artifact" ] && return 0 108 [ "$(uname -s)" = "Darwin" ] || return 0 109 is_apple_silicon_macos || die "macOS desktop artifact requires Apple silicon; expected ${artifact}" 110 111 require_command codesign 112 require_command ditto 113 require_command rustup 114 ensure_tauri_cli 115 rustup target add aarch64-apple-darwin 116 cargo build --release --locked --target aarch64-apple-darwin 117 mkdir -p src-tauri/binaries downloads 118 cp target/aarch64-apple-darwin/release/iuna src-tauri/binaries/iuna-sidecar-aarch64-apple-darwin 119 chmod +x src-tauri/binaries/iuna-sidecar-aarch64-apple-darwin 120 (cd src-tauri && cargo tauri build --target aarch64-apple-darwin --bundles app) 121 122 local app="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/iuna.app" 123 codesign --force --deep --sign - --options runtime "$app" 124 codesign --verify --deep --strict --verbose=4 "$app" 125 ditto -c -k --keepParent "$app" "$artifact" 126 } 127 128 build_windows_desktop_if_possible() { 129 local version="$1" 130 local artifact="downloads/iuna-v${version}-windows-x86_64-desktop-setup.exe" 131 132 [ -f "$artifact" ] && return 0 133 case "$(uname -s)" in 134 MINGW*|MSYS*|CYGWIN*) ;; 135 *) return 0 ;; 136 esac 137 138 ensure_tauri_cli 139 cargo build --release --locked 140 mkdir -p src-tauri/binaries downloads 141 cp target/release/iuna.exe src-tauri/binaries/iuna-sidecar-x86_64-pc-windows-msvc.exe 142 (cd src-tauri && cargo tauri build --bundles nsis) 143 144 local installer 145 installer="$(find src-tauri/target/release/bundle/nsis -maxdepth 1 -type f -name '*.exe' | head -n 1)" 146 [ -n "$installer" ] || die "Windows installer was not produced" 147 cp "$installer" "$artifact" 148 } 149 150 build_windows_desktop_in_docker_if_possible() { 151 local version="$1" 152 local artifact="downloads/iuna-v${version}-windows-x86_64-desktop-setup.exe" 153 154 [ -f "$artifact" ] && return 0 155 command -v docker >/dev/null 2>&1 || return 0 156 157 mkdir -p downloads 158 docker run --rm --platform=linux/amd64 \ 159 -e "IUNA_VERSION=${version}" \ 160 -e "HOST_UID=$(id -u)" \ 161 -e "HOST_GID=$(id -g)" \ 162 -v iuna-windows-cargo-registry:/usr/local/cargo/registry \ 163 -v iuna-windows-cargo-git:/usr/local/cargo/git \ 164 -v iuna-windows-root-cache:/root/.cache \ 165 -v iuna-windows-target:/work/iuna/target \ 166 -v iuna-windows-tauri-target:/work/iuna/src-tauri/target \ 167 -v "$(pwd):/src/iuna:ro" \ 168 -v "$(pwd)/downloads:/out" \ 169 rust:1.86-bookworm \ 170 bash -c ' 171 set -euo pipefail 172 173 apt-get update 174 apt-get install -y --no-install-recommends clang lld llvm nsis 175 rm -rf /var/lib/apt/lists/* 176 rustup target add x86_64-pc-windows-msvc 177 cargo install --locked cargo-xwin --version 0.19.2 178 cargo install --locked tauri-cli --version "^2" 179 180 nsis_utils_path=/root/.cache/tauri/NSIS/Plugins/x86-unicode/additional/nsis_tauri_utils.dll 181 mkdir -p "$(dirname "$nsis_utils_path")" 182 if [ ! -f "$nsis_utils_path" ]; then 183 curl --fail --location --retry 8 --retry-all-errors --retry-delay 3 \ 184 --output "$nsis_utils_path" \ 185 https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll 186 echo "75197fee3c6a814fe035788d1c34ead39349b860 $nsis_utils_path" | sha1sum -c - 187 fi 188 189 mkdir -p /work/iuna 190 tar -C /src/iuna \ 191 --exclude=./target \ 192 --exclude=./src-tauri/target \ 193 --exclude=./src-tauri/binaries \ 194 --exclude=./.agents \ 195 --exclude=./.codex \ 196 -cf - . | tar -C /work/iuna -xf - 197 198 cd /work/iuna 199 cargo xwin build --release --locked --target x86_64-pc-windows-msvc 200 mkdir -p src-tauri/binaries 201 cp target/x86_64-pc-windows-msvc/release/iuna.exe src-tauri/binaries/iuna-sidecar-x86_64-pc-windows-msvc.exe 202 203 cd src-tauri 204 cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis 205 206 installer="$(find target/x86_64-pc-windows-msvc/release/bundle/nsis -maxdepth 1 -type f -name "*setup.exe" | head -n 1)" 207 [ -n "$installer" ] || { echo "Windows installer was not produced" >&2; exit 1; } 208 cp "$installer" "/out/iuna-v${IUNA_VERSION}-windows-x86_64-desktop-setup.exe" 209 chown "${HOST_UID}:${HOST_GID}" "/out/iuna-v${IUNA_VERSION}-windows-x86_64-desktop-setup.exe" 210 ' 211 } 212 213 require_desktop_artifacts() { 214 local version="$1" 215 local macos_artifact="downloads/iuna-v${version}-macos-aarch64-desktop.app.zip" 216 local windows_artifact="downloads/iuna-v${version}-windows-x86_64-desktop-setup.exe" 217 218 [ -f "$macos_artifact" ] || die "missing ${macos_artifact}" 219 [ -f "$windows_artifact" ] || die "missing ${windows_artifact}" 220 } 221 222 build_linux_cli_archives() { 223 local version="$1" 224 local tag="v${version}" 225 local linux_x86_64_package="iuna-${tag}-linux-x86_64" 226 local linux_aarch64_package="iuna-${tag}-linux-aarch64" 227 228 mkdir -p .docker-build downloads 229 [ -f "downloads/${linux_x86_64_package}.tar.gz" ] \ 230 && [ -f "downloads/${linux_aarch64_package}.tar.gz" ] \ 231 && [ -f .docker-build/iuna-node-linux-x86_64 ] \ 232 && return 0 233 234 require_command docker 235 236 docker run --rm --platform=linux/amd64 \ 237 -e "IUNA_VERSION=${version}" \ 238 -e "HOST_UID=$(id -u)" \ 239 -e "HOST_GID=$(id -g)" \ 240 -v "$(pwd):/src/iuna:ro" \ 241 -v "$(pwd)/downloads:/out" \ 242 -v "$(pwd)/.docker-build:/node-out" \ 243 rust:1.86-bookworm \ 244 bash -c ' 245 set -euo pipefail 246 247 apt-get update 248 apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libc6-dev-arm64-cross 249 rm -rf /var/lib/apt/lists/* 250 rustup target add aarch64-unknown-linux-gnu 251 252 mkdir -p /work/iuna 253 tar -C /src/iuna \ 254 --exclude=./target \ 255 --exclude=./src-tauri/target \ 256 --exclude=./src-tauri/binaries \ 257 --exclude=./.agents \ 258 --exclude=./.codex \ 259 --exclude=./.docker-build \ 260 -cf - . | tar -C /work/iuna -xf - 261 262 cd /work/iuna 263 CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ 264 AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \ 265 CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ 266 cargo build --release --locked --target aarch64-unknown-linux-gnu 267 cargo build --release --locked 268 269 tag="v${IUNA_VERSION}" 270 linux_x86_64_package="iuna-${tag}-linux-x86_64" 271 linux_aarch64_package="iuna-${tag}-linux-aarch64" 272 mkdir -p "/tmp/site/${linux_x86_64_package}" "/tmp/site/${linux_aarch64_package}" 273 cp target/release/iuna "/tmp/site/${linux_x86_64_package}/" 274 cp target/aarch64-unknown-linux-gnu/release/iuna "/tmp/site/${linux_aarch64_package}/" 275 cp target/release/iuna /node-out/iuna-node-linux-x86_64 276 cp README.md LICENSE "/tmp/site/${linux_x86_64_package}/" 277 cp README.md LICENSE "/tmp/site/${linux_aarch64_package}/" 278 tar -C /tmp/site -czf "/out/${linux_x86_64_package}.tar.gz" "${linux_x86_64_package}" 279 tar -C /tmp/site -czf "/out/${linux_aarch64_package}.tar.gz" "${linux_aarch64_package}" 280 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 281 ' 282 } 283 284 write_download_checksums() { 285 ( 286 cd downloads 287 rm -f SHA256SUMS 288 289 local files=() 290 local file 291 for file in *; do 292 [ -f "$file" ] || continue 293 case "$file" in 294 .gitkeep|index.html|SHA256SUMS) continue ;; 295 esac 296 files+=("$file") 297 done 298 299 [ "${#files[@]}" -gt 0 ] || return 0 300 if command -v sha256sum >/dev/null 2>&1; then 301 sha256sum "${files[@]}" > SHA256SUMS 302 else 303 for file in "${files[@]}"; do 304 shasum -a 256 "$file" | awk "{print \$1 \" \" \$2}" 305 done > SHA256SUMS 306 fi 307 ) 308 } 309 310 build_versions() { 311 local version="$1" 312 313 mkdir -p downloads 314 build_linux_cli_archives "$version" 315 build_macos_desktop_if_possible "$version" 316 build_windows_desktop_if_possible "$version" 317 build_windows_desktop_in_docker_if_possible "$version" 318 require_desktop_artifacts "$version" 319 write_download_checksums 320 } 321 322 build_docker_image() { 323 local version="$1" 324 local www_image="${IUNA_WWW_IMAGE:-iuna-www:v${version}}" 325 local node_image="${IUNA_NODE_IMAGE:-iuna-node:v${version}}" 326 327 require_command docker 328 329 [ -f .docker-build/iuna-node-linux-x86_64 ] || die "missing .docker-build/iuna-node-linux-x86_64; run build_versions first" 330 331 docker build --platform=linux/amd64 --progress=plain -t "$www_image" . 332 docker build --platform=linux/amd64 --progress=plain -t "$node_image" -f Dockerfile.node . 333 echo "Built Docker images: ${www_image}, ${node_image}" 334 } 335 336 import_image_to_k3s() { 337 local image="$1" 338 local tmp_folder="$2" 339 local remote_host="${IUNA_DEPLOY_HOST:-root@jhx.app}" 340 local remote_file="${image//[:\/]/_}.tar" 341 local image_file="${tmp_folder}/${remote_file}" 342 343 require_command docker 344 require_command scp 345 require_command ssh 346 347 docker save "$image" -o "$image_file" 348 scp "$image_file" "${remote_host}:~/" 349 ssh "$remote_host" "sudo k3s ctr -n k8s.io images import ~/${remote_file} && rm ~/${remote_file}" 350 } 351 352 render_manifest() { 353 local www_image="$1" 354 local node_image="$2" 355 local output="$3" 356 local escaped_www_image 357 local escaped_node_image 358 359 escaped_www_image="$(escape_sed_replacement "$www_image")" 360 escaped_node_image="$(escape_sed_replacement "$node_image")" 361 362 sed \ 363 -e "s|\${IUNA_WWW_IMAGE}|${escaped_www_image}|g" \ 364 -e "s|\${IUNA_NODE_IMAGE}|${escaped_node_image}|g" \ 365 config/deployment.yml > "$output" 366 } 367 368 deploy_docker_image() { 369 local version="$1" 370 local www_image="${IUNA_WWW_IMAGE:-iuna-www:v${version}}" 371 local node_image="${IUNA_NODE_IMAGE:-iuna-node:v${version}}" 372 local kubectl_context="${IUNA_KUBECTL_CONTEXT:-jhx-app}" 373 local tmp_folder 374 375 require_command kubectl 376 377 tmp_folder="$(mktemp -d)" 378 trap 'rm -rf "$tmp_folder"' RETURN 379 380 import_image_to_k3s "$www_image" "$tmp_folder" 381 import_image_to_k3s "$node_image" "$tmp_folder" 382 render_manifest "$www_image" "$node_image" "${tmp_folder}/deployment.yml" 383 384 local current_www_selector 385 current_www_selector="$(kubectl --context "$kubectl_context" -n iuna get deployment www -o jsonpath='{.spec.selector.matchLabels.app}' 2>/dev/null || true)" 386 if [ -n "$current_www_selector" ] && [ "$current_www_selector" != "iuna-www" ]; then 387 kubectl --context "$kubectl_context" -n iuna delete deployment www --wait=true 388 fi 389 390 kubectl --context "$kubectl_context" apply -f "${tmp_folder}/deployment.yml" 391 kubectl --context "$kubectl_context" -n iuna rollout restart deployment/www deployment/node 392 kubectl --context "$kubectl_context" -n iuna rollout status deployment/www 393 kubectl --context "$kubectl_context" -n iuna rollout status deployment/node 394 } 395 396 main() { 397 [ "$#" -eq 1 ] || { usage; exit 2; } 398 399 local version="${1#v}" 400 [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "version must look like 0.2.48" 401 402 ensure_clean_worktree 403 404 # Check if the tag already exists; if it does, only deploy 405 if git rev-parse --verify "v${version}" >/dev/null 2>&1; then 406 ensure_head_matches_tag "v${version}" 407 echo "Tag v${version} already exists; rebuilding Docker images and deploying" 408 if ! confirm "Are you sure you want to deploy v${version}? (y/N) "; then 409 echo "Aborting deployment" 410 exit 1 411 fi 412 build_linux_cli_archives "$version" 413 build_docker_image "$version" 414 deploy_docker_image "$version" 415 exit 0 416 fi 417 418 update_versions "$version" 419 build_versions "$version" 420 commit_and_tag "$version" 421 build_docker_image "$version" 422 deploy_docker_image "$version" 423 } 424 425 main "$@"