iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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