scripts: add make-release.sh #5

Open
jann wants to merge 2 commits from jann/m2dev-client:claude/make-release-script into main
2 changed files with 184 additions and 6 deletions

View File

@@ -163,14 +163,23 @@ Files under `.updates/` are created by the launcher. The user shouldn't touch th
1. On a trusted machine (not random laptop), with the private signing key present:
```bash
./scripts/make-release.sh --version 2026.04.14-1 --source /path/to/fresh/client
./scripts/make-release.sh --source /path/to/fresh/client --version 2026.04.14-1 \
--previous 2026.04.13-3 --notes notes.md --dry-run
```
2. The script walks the client directory, computes sha256 for each file, writes a `manifest.json`, signs it, and produces a release directory `release/2026.04.14-1/` containing the manifest, its signature, and only the new blobs (ones not already present on the server).
[`scripts/make-release.sh`](../scripts/make-release.sh) is the single entry
point for the v1 manual flow. It drives `make-manifest.py` + `sign-manifest.py`,
builds the content-addressed blob tree under `files/<hash[0:2]>/<hash>` with
hardlink-based deduplication, archives the signed manifest into
`manifests/<version>.json`, and — unless `--dry-run` is passed — rsyncs the
blob tree first and the `manifest.json` + `manifest.json.sig` pair last so the
release becomes visible atomically. Flags: `--key` (default
`~/.config/metin/launcher-signing-key`, must be mode 600), `--out` (default
`/tmp/release-<version>`), `--force` to overwrite a non-empty out dir, `--yes`
to skip the interactive rsync confirmation, `--rsync-target <user@host:/path>`
to override the upload destination.
2. The script walks the client directory, computes sha256 for each file, writes a `manifest.json`, signs it, and produces a release directory containing the manifest, its signature, and the deduplicated blob tree.
3. Human review: diff the new manifest against the previous one, sanity-check size and file count.
4. `rsync` the release directory to the VPS:
```bash
rsync -av release/2026.04.14-1/ mt2.jakubkadlec.dev@mt2.jakubkadlec.dev:/var/www/updates.jakubkadlec.dev/
```
4. Re-run without `--dry-run` (same args) to rsync to the VPS. The script prints the target and waits for confirmation unless `--yes` is passed.
5. Verify from a second machine: `curl` the manifest, check signature, check a random blob.
6. Tag the release in git.

169
scripts/make-release.sh Executable file
View File

@@ -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 <client-dir> --version <version> \
# [--previous <version>] [--notes <file>] \
# [--key <path>] [--out <path>] [--force] \
# [--dry-run] [--yes] [--rsync-target <user@host:/path>]
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."