#!/usr/bin/env bash # make-release.sh — assemble, sign, and (optionally) publish a client release. # # Drives scripts/make-manifest.py + scripts/sign-manifest.py, then builds the # content-addressed blob tree the launcher pulls from. See docs/update-manager.md # for the end-to-end design; this script is the v1 manual publishing flow. # # Usage: # scripts/make-release.sh --source --version \ # [--previous ] [--notes ] \ # [--key ] [--out ] [--force] \ # [--dry-run] [--yes] [--rsync-target ] set -euo pipefail # -------- arg parsing -------- SOURCE="" VERSION="" PREVIOUS="" NOTES_FILE="" KEY="${HOME}/.config/metin/launcher-signing-key" OUT="" FORCE=0 DRY_RUN=0 YES=0 RSYNC_TARGET="mt2.jakubkadlec.dev@mt2.jakubkadlec.dev:/var/www/updates.jakubkadlec.dev/" die() { echo "error: $*" >&2; exit 1; } say() { echo "[make-release] $*"; } while [[ $# -gt 0 ]]; do case "$1" in --source) SOURCE="$2"; shift 2 ;; --version) VERSION="$2"; shift 2 ;; --previous) PREVIOUS="$2"; shift 2 ;; --notes) NOTES_FILE="$2"; shift 2 ;; --key) KEY="$2"; shift 2 ;; --out) OUT="$2"; shift 2 ;; --force) FORCE=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --yes) YES=1; shift ;; --rsync-target) RSYNC_TARGET="$2"; shift 2 ;; -h|--help) sed -n '1,15p' "$0"; exit 0 ;; *) die "unknown arg: $1" ;; esac done [[ -n "$SOURCE" ]] || die "--source is required" [[ -n "$VERSION" ]] || die "--version is required" [[ -d "$SOURCE" ]] || die "source dir does not exist: $SOURCE" [[ -f "$SOURCE/Metin2.exe" ]] || die "source does not look like a client dir (missing Metin2.exe): $SOURCE" [[ -f "$KEY" ]] || die "signing key not found: $KEY" KEY_MODE=$(stat -c '%a' "$KEY") [[ "$KEY_MODE" == "600" ]] || die "signing key $KEY must be mode 600, got $KEY_MODE" [[ -n "$NOTES_FILE" && ! -f "$NOTES_FILE" ]] && die "notes file not found: $NOTES_FILE" : "${OUT:=/tmp/release-${VERSION}}" SOURCE=$(cd "$SOURCE" && pwd) OUT_ABS=$(mkdir -p "$OUT" && cd "$OUT" && pwd) if [[ -n "$(ls -A "$OUT_ABS" 2>/dev/null)" && "$FORCE" -ne 1 ]]; then die "output directory $OUT_ABS is non-empty (use --force to overwrite)" fi SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) say "source: $SOURCE" say "version: $VERSION" say "out: $OUT_ABS" say "key: $KEY" # -------- [1/6] manifest -------- say "[1/6] building manifest" mkdir -p "$OUT_ABS/manifests" "$OUT_ABS/files" MANIFEST="$OUT_ABS/manifest.json" mk_args=(--source "$SOURCE" --version "$VERSION" --out "$MANIFEST") [[ -n "$PREVIOUS" ]] && mk_args+=(--previous "$PREVIOUS") if [[ -n "$NOTES_FILE" ]]; then notes_text=$(cat "$NOTES_FILE") mk_args+=(--notes "$notes_text") fi python3 "$SCRIPT_DIR/make-manifest.py" "${mk_args[@]}" # -------- [2/6] sign -------- say "[2/6] signing manifest" python3 "$SCRIPT_DIR/sign-manifest.py" --manifest "$MANIFEST" --key "$KEY" SIG="$MANIFEST.sig" [[ -f "$SIG" ]] || die "signature not produced" sig_len=$(stat -c '%s' "$SIG") [[ "$sig_len" == "64" ]] || die "signature is $sig_len bytes, expected 64" # -------- [3/6] archive historical manifest -------- say "[3/6] archiving historical manifest -> manifests/${VERSION}.json" cp -f "$MANIFEST" "$OUT_ABS/manifests/${VERSION}.json" cp -f "$SIG" "$OUT_ABS/manifests/${VERSION}.json.sig" # -------- [4/6] blob tree -------- say "[4/6] building content-addressed blob tree" # Extract (path, sha256) pairs for launcher + every file entry. mapfile -t PAIRS < <(jq -r ' ([.launcher] + .files) | .[] | "\(.sha256)\t\(.path)" ' "$MANIFEST") total_entries=${#PAIRS[@]} unique_count=0 dedup_count=0 bytes_written=0 declare -A SEEN for pair in "${PAIRS[@]}"; do hash="${pair%%$'\t'*}" rel="${pair#*$'\t'}" src="$SOURCE/$rel" [[ -f "$src" ]] || die "file in manifest missing from source: $rel" if [[ -n "${SEEN[$hash]:-}" ]]; then dedup_count=$((dedup_count + 1)) continue fi SEEN[$hash]=1 unique_count=$((unique_count + 1)) prefix="${hash:0:2}" dst_dir="$OUT_ABS/files/$prefix" dst="$dst_dir/$hash" mkdir -p "$dst_dir" if [[ -f "$dst" ]]; then continue fi # Try hardlink first, fall back to copy across filesystems. if ! cp -l "$src" "$dst" 2>/dev/null; then cp "$src" "$dst" fi sz=$(stat -c '%s' "$dst") bytes_written=$((bytes_written + sz)) done say " entries: $total_entries unique blobs: $unique_count deduped: $dedup_count" say " bytes written: $bytes_written" # -------- [5/6] layout summary -------- say "[5/6] final layout:" (cd "$OUT_ABS" && find . -maxdepth 2 -mindepth 1 -printf ' %p\n' | sort | head -40) # -------- [6/6] rsync -------- if [[ "$DRY_RUN" -eq 1 ]]; then say "[6/6] --dry-run set, skipping rsync. target would be: $RSYNC_TARGET" exit 0 fi say "[6/6] rsync target: $RSYNC_TARGET" if [[ "$YES" -ne 1 ]]; then read -r -p "continue? [y/N] " ans [[ "$ans" == "y" || "$ans" == "Y" ]] || die "aborted by user" fi # Stage 1: everything except manifest.json(.sig) — blobs and historical archive. rsync -av --delay-updates --checksum --omit-dir-times --no-perms \ --exclude 'manifest.json' --exclude 'manifest.json.sig' \ "$OUT_ABS"/ "$RSYNC_TARGET" # Stage 2: manifest + signature, so the new release becomes visible last. rsync -av --checksum --omit-dir-times --no-perms \ "$MANIFEST" "$SIG" "$RSYNC_TARGET" say "done."