From 79588a8bba1648ea4d396a1d1e9884af54e497e2 Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 30 Oct 2026 00:40:10 +0100 Subject: add deploy script with live verification; cache file hashes by size and mtime --- scripts/deploy.sh | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++ scripts/publish.sh | 3 +- web/README.md | 28 ++++++++++- web/index.php | 144 ++++++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 295 insertions(+), 16 deletions(-) create mode 100755 scripts/deploy.sh diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..99129ad --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Upload web/ and verify every published artifact over the public URL. +# +# Usage: +# ./scripts/deploy.sh user@host:/srv/zagan.bet https://zagan.bet/ +# ./scripts/deploy.sh /srv/staging http://127.0.0.1:8899/ +# ./scripts/deploy.sh --verify-only user@host:/srv/zagan.bet https://zagan.bet/ +# +# The target may be a local directory, which is handy for staging and for +# testing the verification itself. Old versions are left in place on purpose; +# prune them by hand when you want to drop them. +# +# Environment: +# DEPLOY_TARGET, DEPLOY_URL same as the two arguments +# SSH_PORT ssh port for a remote target +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" + +die() { echo "deploy: $*" >&2; exit 1; } + +VERIFY_ONLY=0 +if [ "${1:-}" = "--verify-only" ]; then + VERIFY_ONLY=1 + shift +fi + +TARGET=${1:-${DEPLOY_TARGET:-}} +URL=${2:-${DEPLOY_URL:-}} +if [ -z "$TARGET" ] || [ -z "$URL" ]; then + cat >&2 <<'EOF' +usage: scripts/deploy.sh [--verify-only] + + target user@host:/srv/zagan.bet or a local directory + url public URL of the page, e.g. https://zagan.bet/ + --verify-only skips the upload and only checks the live page + +environment: + DEPLOY_TARGET, DEPLOY_URL same as the two arguments + SSH_PORT ssh port for a remote target +EOF + exit 2 +fi + +for tool in rsync curl sha256sum; do + command -v "$tool" >/dev/null || die "$tool is required" +done + +[ -f web/index.php ] || die "web/index.php is missing" + +shopt -s nullglob +artifacts=(web/bin/client/zagan-bet-* web/bin/server/zagan-bet-*) +shopt -u nullglob +[ ${#artifacts[@]} -gt 0 ] || die "no artifacts in web/bin; run scripts/publish.sh first" + +if [ "$VERIFY_ONLY" -eq 0 ]; then + echo "==> uploading web/ to $TARGET" + + # --delay-updates writes to temp names and renames at the end, so a visitor + # never sees a half-written binary. Old versions are kept: no --delete. + RSYNC_FLAGS=( + -a --delay-updates --human-readable --chmod=D755,F644 + --exclude=README.md --exclude=.gitignore + ) + + if [[ "$TARGET" == *:* ]]; then + host=${TARGET%%:*} + path=${TARGET#*:} + ssh_args=() + if [ -n "${SSH_PORT:-}" ]; then + ssh_args+=(-p "$SSH_PORT") + fi + ssh ${ssh_args[@]+"${ssh_args[@]}"} "$host" "mkdir -p $(printf '%q' "$path")" + rsync "${RSYNC_FLAGS[@]}" -e "ssh ${ssh_args[*]:-}" web/ "$host:$path/" + else + mkdir -p "$TARGET" + rsync "${RSYNC_FLAGS[@]}" web/ "$TARGET/" + fi +fi + +echo "==> verifying $URL" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +curl -fsSL --max-time 30 "$URL" -o "$tmp/page.html" \ + || die "could not fetch $URL" + +origin=$(printf '%s' "$URL" | sed -E 's#^(https?://[^/]+).*#\1#') +grep -oE '[0-9a-f]{64} [A-Za-z0-9._-]+' "$tmp/page.html" > "$tmp/sums" || true +[ -s "$tmp/sums" ] || die "no SHA-256 lines on the page (is index.php being served?)" + +failed=0 +verified=0 +for local in "${artifacts[@]}"; do + [ -f "$local" ] || continue + + file=$(basename "$local") + kind=$(basename "$(dirname "$local")") + local_hash=$(sha256sum "$local" | cut -d' ' -f1) + + href=$(grep -oE "href=\"[^\"]*/bin/$kind/$file\"" "$tmp/page.html" | head -1 \ + | sed 's/^href="//;s/"$//' || true) + if [ -z "$href" ]; then + echo " $file: not listed on the page" + failed=1 + continue + fi + + shown=$(awk -v name="$file" '$2 == name { print $1 }' "$tmp/sums" | head -1) + if [ "$shown" != "$local_hash" ]; then + echo " $file: page shows ${shown:-nothing}, local is $local_hash" + failed=1 + continue + fi + + if ! curl -fsSL --max-time 120 "$origin$href" -o "$tmp/$file"; then + echo " $file: download failed" + failed=1 + continue + fi + + remote_hash=$(sha256sum "$tmp/$file" | cut -d' ' -f1) + if [ "$remote_hash" != "$local_hash" ]; then + echo " $file: downloaded bytes differ from local" + failed=1 + continue + fi + + echo " $file: ok" + verified=$((verified + 1)) +done + +[ "$failed" -eq 0 ] || die "verification failed" + +echo "==> $verified artifact(s) verified at $URL" diff --git a/scripts/publish.sh b/scripts/publish.sh index d8cc77a..8240859 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -27,7 +27,7 @@ DEST=${DEST:-web/bin} publish() { local triple=$1 kind=$2 platform=$3 arch=$4 local ext="" - if [ "$kind" = client ] && [ "$platform" = windows ]; then + if [ "$platform" = windows ]; then ext=".exe" fi @@ -51,6 +51,7 @@ publish x86_64-unknown-linux-gnu client linux x86_64 publish i686-unknown-linux-gnu client linux i686 publish x86_64-pc-windows-gnu client windows x86_64 publish x86_64-unknown-linux-gnu server linux x86_64 +publish x86_64-pc-windows-gnu server windows x86_64 echo echo "published $VERSION to $DEST" diff --git a/web/README.md b/web/README.md index 5de180d..a82c492 100644 --- a/web/README.md +++ b/web/README.md @@ -58,10 +58,34 @@ php -S 127.0.0.1:8899 -t web Then open . +## Deploying + +```sh +./scripts/publish.sh +./scripts/deploy.sh user@host:/srv/zagan.bet https://zagan.bet/ +``` + +`deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a +half-written binary, then fetches the live page and every artifact it lists and +compares the downloaded bytes with the local SHA-256. `--verify-only` skips the +upload and only checks the live site. Old versions are left on the server; +prune them by hand. + +A local directory works as a target, which is how the verification itself is +tested: + +```sh +php -S 127.0.0.1:8899 -t /srv/staging +./scripts/deploy.sh /srv/staging http://127.0.0.1:8899/ +``` + ## Notes -- Hashes are computed per request with `hash_file()`. That is fine for a - handful of builds; add an mtime-keyed cache if the directory grows. +- Hashes are cached in a JSON file keyed on size and mtime, so a file is only + read once until it changes. The cache lives in the system temp directory by + default because the web root is often not writable by the PHP user; set + `ZAGAN_HASH_CACHE` to put it elsewhere. If the cache cannot be written, the + page simply hashes every request. - The page lists every build it finds, newest version first, so old releases stay downloadable until you remove them from `bin/`. - The checksum block is in `sha256sum` format, so visitors can save it as diff --git a/web/index.php b/web/index.php index 361fdcf..fd0a396 100644 --- a/web/index.php +++ b/web/index.php @@ -19,9 +19,10 @@ declare(strict_types=1); * bin/client/zagan-bet-client-3.1.1-linux-x86_64 * bin/server/zagan-bet-server-3.1.1-linux-x86_64 * - * The hashes are computed per request with hash_file(). That is a few tens of - * milliseconds for a handful of builds; add a cache keyed on mtime if the - * directory ever grows. + * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file + * is only re-read when it changes. The cache lives in the system temp + * directory by default (the web root is often not writable by the PHP user); + * set ZAGAN_HASH_CACHE to put it somewhere else. */ const KINDS = [ @@ -68,10 +69,99 @@ function human_size(int $bytes): string } /** - * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null + * Hash to use for a published file, reusing the cached value when the file has + * not changed since it was hashed. + * + * A cached hash is valid only when the size and mtime still match and the hash + * was taken at or after the file's mtime, so a restored or touched file is + * re-read. + * + * @param array{files:array} $cache + */ +function cached_hash( + string $key, + string $path, + int $size, + int $mtime, + array &$cache, + bool &$dirty +): ?string { + $entry = $cache['files'][$key] ?? null; + if ( + is_array($entry) + && ($entry['size'] ?? null) === $size + && ($entry['mtime'] ?? null) === $mtime + && (int) ($entry['hashed_at'] ?? 0) >= $mtime + && isset($entry['sha256']) + && is_string($entry['sha256']) + && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1 + ) { + return $entry['sha256']; + } + + $hash = hash_file('sha256', $path); + if ($hash === false) { + return null; + } + + $cache['files'][$key] = [ + 'size' => $size, + 'mtime' => $mtime, + 'sha256' => $hash, + 'hashed_at' => time(), + ]; + $dirty = true; + + return $hash; +} + +/** @return array{files:array} */ +function load_hash_cache(string $path): array +{ + $raw = @file_get_contents($path); + if ($raw === false || $raw === '') { + return ['files' => []]; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) { + return ['files' => []]; + } + + return ['files' => $decoded['files']]; +} + +/** + * Best effort: a read-only web root, a full disk or a racing writer must never + * break the page, so failures are ignored and the next request recomputes. + * + * @param array{files:array} $cache */ -function read_release(string $kind, string $directory, string $entry): ?array +function save_hash_cache(string $path, array $cache): void { + $json = json_encode($cache, JSON_UNESCAPED_SLASHES); + if ($json === false) { + return; + } + $directory = dirname($path); + $temporary = @tempnam($directory, '.zagan-hashes-'); + if ($temporary === false) { + return; + } + if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) { + @unlink($temporary); + } +} + +/** + * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null + */ +function read_release( + string $kind, + string $directory, + string $entry, + array &$cache, + bool &$dirty +): ?array { $pattern = '/^zagan-bet-' . preg_quote($kind, '/') . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i'; if (preg_match($pattern, $entry, $matches) !== 1) { @@ -92,8 +182,13 @@ function read_release(string $kind, string $directory, string $entry): ?array } $size = filesize($real); - $hash = hash_file('sha256', $real); - if ($size === false || $hash === false) { + $mtime = filemtime($real); + if ($size === false || $mtime === false) { + return null; + } + + $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty); + if ($hash === null) { return null; } @@ -103,7 +198,7 @@ function read_release(string $kind, string $directory, string $entry): ?array 'arch' => strtolower($matches[3]), 'file' => $entry, 'size' => $size, - 'mtime' => filemtime($real) ?: 0, + 'mtime' => $mtime, 'sha256' => $hash, ]; } @@ -113,6 +208,16 @@ if ($base === '.' || $base === '/') { $base = ''; } +$cachePath = getenv('ZAGAN_HASH_CACHE'); +if (!is_string($cachePath) || $cachePath === '') { + $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-' + . substr(hash('sha256', __DIR__), 0, 16) . '.json'; +} +$cache = load_hash_cache($cachePath); +$cacheDirty = false; +$seen = []; + $releases = []; $newest = 0; @@ -127,10 +232,11 @@ foreach (KINDS as $kind => $meta) { if ($entry === '.' || $entry === '..') { continue; } - $release = read_release($kind, $directory, $entry); + $release = read_release($kind, $directory, $entry, $cache, $cacheDirty); if ($release === null) { continue; } + $seen[$kind . '/' . $entry] = true; $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); $found[] = $release; $newest = max($newest, $release['mtime']); @@ -156,19 +262,30 @@ foreach (KINDS as $kind => $meta) { $releases[$kind] = $found; } + +// Drop cache entries for files that are no longer published. +foreach (array_keys($cache['files']) as $key) { + if (!isset($seen[$key])) { + unset($cache['files'][$key]); + $cacheDirty = true; + } +} +if ($cacheDirty) { + save_hash_cache($cachePath, $cache); +} ?> -Download · zagan.bet +żagań.bet -

zagan.bet

+

żagań.bet

Official client and server builds.

0): ?>

Builds published UTC.

@@ -181,7 +298,7 @@ foreach (KINDS as $kind => $meta) { @@ -193,7 +310,7 @@ foreach (KINDS as $kind => $meta) { @@ -222,5 +339,6 @@ echo e($sums);

Windows: run the .exe from Windows Terminal or another real terminal.

+

When lost, execute help command inside zagan.bet client. Server binary supports --help flag

-- cgit v1.2.3