From c2804fe1a639ba7c06bc8f6ced67f6eedcd976a6 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 13:52:23 +0200 Subject: [PATCH] scripts: add make-release.sh --- scripts/make-release.sh | 169 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100755 scripts/make-release.sh diff --git a/scripts/make-release.sh b/scripts/make-release.sh new file mode 100755 index 00000000..2f563bbd --- /dev/null +++ b/scripts/make-release.sh @@ -0,0 +1,169 @@ +#!/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."