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