summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-30 00:40:10 +0100
committersalmonik <salmonik@tuta.io>2026-10-30 00:40:10 +0100
commit79588a8bba1648ea4d396a1d1e9884af54e497e2 (patch)
treebcc795839e1b848bb8e4c5ba017aaef6e9399ecb
parentadd publish script that names release artifacts from Cargo.toml and the targe... (diff)
downloadzagan-bet-79588a8bba1648ea4d396a1d1e9884af54e497e2.tar.gz
zagan-bet-79588a8bba1648ea4d396a1d1e9884af54e497e2.zip
add deploy script with live verification; cache file hashes by size and mtime
-rwxr-xr-xscripts/deploy.sh136
-rwxr-xr-xscripts/publish.sh3
-rw-r--r--web/README.md28
-rw-r--r--web/index.php144
4 files changed, 295 insertions, 16 deletions
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 @@
1#!/usr/bin/env bash
2# Upload web/ and verify every published artifact over the public URL.
3#
4# Usage:
5# ./scripts/deploy.sh user@host:/srv/zagan.bet https://zagan.bet/
6# ./scripts/deploy.sh /srv/staging http://127.0.0.1:8899/
7# ./scripts/deploy.sh --verify-only user@host:/srv/zagan.bet https://zagan.bet/
8#
9# The target may be a local directory, which is handy for staging and for
10# testing the verification itself. Old versions are left in place on purpose;
11# prune them by hand when you want to drop them.
12#
13# Environment:
14# DEPLOY_TARGET, DEPLOY_URL same as the two arguments
15# SSH_PORT ssh port for a remote target
16set -euo pipefail
17
18ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
19cd "$ROOT"
20
21die() { echo "deploy: $*" >&2; exit 1; }
22
23VERIFY_ONLY=0
24if [ "${1:-}" = "--verify-only" ]; then
25 VERIFY_ONLY=1
26 shift
27fi
28
29TARGET=${1:-${DEPLOY_TARGET:-}}
30URL=${2:-${DEPLOY_URL:-}}
31if [ -z "$TARGET" ] || [ -z "$URL" ]; then
32 cat >&2 <<'EOF'
33usage: scripts/deploy.sh [--verify-only] <target> <url>
34
35 target user@host:/srv/zagan.bet or a local directory
36 url public URL of the page, e.g. https://zagan.bet/
37 --verify-only skips the upload and only checks the live page
38
39environment:
40 DEPLOY_TARGET, DEPLOY_URL same as the two arguments
41 SSH_PORT ssh port for a remote target
42EOF
43 exit 2
44fi
45
46for tool in rsync curl sha256sum; do
47 command -v "$tool" >/dev/null || die "$tool is required"
48done
49
50[ -f web/index.php ] || die "web/index.php is missing"
51
52shopt -s nullglob
53artifacts=(web/bin/client/zagan-bet-* web/bin/server/zagan-bet-*)
54shopt -u nullglob
55[ ${#artifacts[@]} -gt 0 ] || die "no artifacts in web/bin; run scripts/publish.sh first"
56
57if [ "$VERIFY_ONLY" -eq 0 ]; then
58 echo "==> uploading web/ to $TARGET"
59
60 # --delay-updates writes to temp names and renames at the end, so a visitor
61 # never sees a half-written binary. Old versions are kept: no --delete.
62 RSYNC_FLAGS=(
63 -a --delay-updates --human-readable --chmod=D755,F644
64 --exclude=README.md --exclude=.gitignore
65 )
66
67 if [[ "$TARGET" == *:* ]]; then
68 host=${TARGET%%:*}
69 path=${TARGET#*:}
70 ssh_args=()
71 if [ -n "${SSH_PORT:-}" ]; then
72 ssh_args+=(-p "$SSH_PORT")
73 fi
74 ssh ${ssh_args[@]+"${ssh_args[@]}"} "$host" "mkdir -p $(printf '%q' "$path")"
75 rsync "${RSYNC_FLAGS[@]}" -e "ssh ${ssh_args[*]:-}" web/ "$host:$path/"
76 else
77 mkdir -p "$TARGET"
78 rsync "${RSYNC_FLAGS[@]}" web/ "$TARGET/"
79 fi
80fi
81
82echo "==> verifying $URL"
83tmp=$(mktemp -d)
84trap 'rm -rf "$tmp"' EXIT
85
86curl -fsSL --max-time 30 "$URL" -o "$tmp/page.html" \
87 || die "could not fetch $URL"
88
89origin=$(printf '%s' "$URL" | sed -E 's#^(https?://[^/]+).*#\1#')
90grep -oE '[0-9a-f]{64} [A-Za-z0-9._-]+' "$tmp/page.html" > "$tmp/sums" || true
91[ -s "$tmp/sums" ] || die "no SHA-256 lines on the page (is index.php being served?)"
92
93failed=0
94verified=0
95for local in "${artifacts[@]}"; do
96 [ -f "$local" ] || continue
97
98 file=$(basename "$local")
99 kind=$(basename "$(dirname "$local")")
100 local_hash=$(sha256sum "$local" | cut -d' ' -f1)
101
102 href=$(grep -oE "href=\"[^\"]*/bin/$kind/$file\"" "$tmp/page.html" | head -1 \
103 | sed 's/^href="//;s/"$//' || true)
104 if [ -z "$href" ]; then
105 echo " $file: not listed on the page"
106 failed=1
107 continue
108 fi
109
110 shown=$(awk -v name="$file" '$2 == name { print $1 }' "$tmp/sums" | head -1)
111 if [ "$shown" != "$local_hash" ]; then
112 echo " $file: page shows ${shown:-nothing}, local is $local_hash"
113 failed=1
114 continue
115 fi
116
117 if ! curl -fsSL --max-time 120 "$origin$href" -o "$tmp/$file"; then
118 echo " $file: download failed"
119 failed=1
120 continue
121 fi
122
123 remote_hash=$(sha256sum "$tmp/$file" | cut -d' ' -f1)
124 if [ "$remote_hash" != "$local_hash" ]; then
125 echo " $file: downloaded bytes differ from local"
126 failed=1
127 continue
128 fi
129
130 echo " $file: ok"
131 verified=$((verified + 1))
132done
133
134[ "$failed" -eq 0 ] || die "verification failed"
135
136echo "==> $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}
27publish() { 27publish() {
28 local triple=$1 kind=$2 platform=$3 arch=$4 28 local triple=$1 kind=$2 platform=$3 arch=$4
29 local ext="" 29 local ext=""
30 if [ "$kind" = client ] && [ "$platform" = windows ]; then 30 if [ "$platform" = windows ]; then
31 ext=".exe" 31 ext=".exe"
32 fi 32 fi
33 33
@@ -51,6 +51,7 @@ publish x86_64-unknown-linux-gnu client linux x86_64
51publish i686-unknown-linux-gnu client linux i686 51publish i686-unknown-linux-gnu client linux i686
52publish x86_64-pc-windows-gnu client windows x86_64 52publish x86_64-pc-windows-gnu client windows x86_64
53publish x86_64-unknown-linux-gnu server linux x86_64 53publish x86_64-unknown-linux-gnu server linux x86_64
54publish x86_64-pc-windows-gnu server windows x86_64
54 55
55echo 56echo
56echo "published $VERSION to $DEST" 57echo "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
58 58
59Then open <http://127.0.0.1:8899/>. 59Then open <http://127.0.0.1:8899/>.
60 60
61## Deploying
62
63```sh
64./scripts/publish.sh
65./scripts/deploy.sh user@host:/srv/zagan.bet https://zagan.bet/
66```
67
68`deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a
69half-written binary, then fetches the live page and every artifact it lists and
70compares the downloaded bytes with the local SHA-256. `--verify-only` skips the
71upload and only checks the live site. Old versions are left on the server;
72prune them by hand.
73
74A local directory works as a target, which is how the verification itself is
75tested:
76
77```sh
78php -S 127.0.0.1:8899 -t /srv/staging
79./scripts/deploy.sh /srv/staging http://127.0.0.1:8899/
80```
81
61## Notes 82## Notes
62 83
63- Hashes are computed per request with `hash_file()`. That is fine for a 84- Hashes are cached in a JSON file keyed on size and mtime, so a file is only
64 handful of builds; add an mtime-keyed cache if the directory grows. 85 read once until it changes. The cache lives in the system temp directory by
86 default because the web root is often not writable by the PHP user; set
87 `ZAGAN_HASH_CACHE` to put it elsewhere. If the cache cannot be written, the
88 page simply hashes every request.
65- The page lists every build it finds, newest version first, so old releases 89- The page lists every build it finds, newest version first, so old releases
66 stay downloadable until you remove them from `bin/`. 90 stay downloadable until you remove them from `bin/`.
67- The checksum block is in `sha256sum` format, so visitors can save it as 91- 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);
19 * bin/client/zagan-bet-client-3.1.1-linux-x86_64 19 * bin/client/zagan-bet-client-3.1.1-linux-x86_64
20 * bin/server/zagan-bet-server-3.1.1-linux-x86_64 20 * bin/server/zagan-bet-server-3.1.1-linux-x86_64
21 * 21 *
22 * The hashes are computed per request with hash_file(). That is a few tens of 22 * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file
23 * milliseconds for a handful of builds; add a cache keyed on mtime if the 23 * is only re-read when it changes. The cache lives in the system temp
24 * directory ever grows. 24 * directory by default (the web root is often not writable by the PHP user);
25 * set ZAGAN_HASH_CACHE to put it somewhere else.
25 */ 26 */
26 27
27const KINDS = [ 28const KINDS = [
@@ -68,10 +69,99 @@ function human_size(int $bytes): string
68} 69}
69 70
70/** 71/**
71 * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null 72 * Hash to use for a published file, reusing the cached value when the file has
73 * not changed since it was hashed.
74 *
75 * A cached hash is valid only when the size and mtime still match and the hash
76 * was taken at or after the file's mtime, so a restored or touched file is
77 * re-read.
78 *
79 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
80 */
81function cached_hash(
82 string $key,
83 string $path,
84 int $size,
85 int $mtime,
86 array &$cache,
87 bool &$dirty
88): ?string {
89 $entry = $cache['files'][$key] ?? null;
90 if (
91 is_array($entry)
92 && ($entry['size'] ?? null) === $size
93 && ($entry['mtime'] ?? null) === $mtime
94 && (int) ($entry['hashed_at'] ?? 0) >= $mtime
95 && isset($entry['sha256'])
96 && is_string($entry['sha256'])
97 && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1
98 ) {
99 return $entry['sha256'];
100 }
101
102 $hash = hash_file('sha256', $path);
103 if ($hash === false) {
104 return null;
105 }
106
107 $cache['files'][$key] = [
108 'size' => $size,
109 'mtime' => $mtime,
110 'sha256' => $hash,
111 'hashed_at' => time(),
112 ];
113 $dirty = true;
114
115 return $hash;
116}
117
118/** @return array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} */
119function load_hash_cache(string $path): array
120{
121 $raw = @file_get_contents($path);
122 if ($raw === false || $raw === '') {
123 return ['files' => []];
124 }
125 $decoded = json_decode($raw, true);
126 if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) {
127 return ['files' => []];
128 }
129
130 return ['files' => $decoded['files']];
131}
132
133/**
134 * Best effort: a read-only web root, a full disk or a racing writer must never
135 * break the page, so failures are ignored and the next request recomputes.
136 *
137 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
72 */ 138 */
73function read_release(string $kind, string $directory, string $entry): ?array 139function save_hash_cache(string $path, array $cache): void
74{ 140{
141 $json = json_encode($cache, JSON_UNESCAPED_SLASHES);
142 if ($json === false) {
143 return;
144 }
145 $directory = dirname($path);
146 $temporary = @tempnam($directory, '.zagan-hashes-');
147 if ($temporary === false) {
148 return;
149 }
150 if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) {
151 @unlink($temporary);
152 }
153}
154
155/**
156 * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null
157 */
158function read_release(
159 string $kind,
160 string $directory,
161 string $entry,
162 array &$cache,
163 bool &$dirty
164): ?array {
75 $pattern = '/^zagan-bet-' . preg_quote($kind, '/') 165 $pattern = '/^zagan-bet-' . preg_quote($kind, '/')
76 . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i'; 166 . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i';
77 if (preg_match($pattern, $entry, $matches) !== 1) { 167 if (preg_match($pattern, $entry, $matches) !== 1) {
@@ -92,8 +182,13 @@ function read_release(string $kind, string $directory, string $entry): ?array
92 } 182 }
93 183
94 $size = filesize($real); 184 $size = filesize($real);
95 $hash = hash_file('sha256', $real); 185 $mtime = filemtime($real);
96 if ($size === false || $hash === false) { 186 if ($size === false || $mtime === false) {
187 return null;
188 }
189
190 $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty);
191 if ($hash === null) {
97 return null; 192 return null;
98 } 193 }
99 194
@@ -103,7 +198,7 @@ function read_release(string $kind, string $directory, string $entry): ?array
103 'arch' => strtolower($matches[3]), 198 'arch' => strtolower($matches[3]),
104 'file' => $entry, 199 'file' => $entry,
105 'size' => $size, 200 'size' => $size,
106 'mtime' => filemtime($real) ?: 0, 201 'mtime' => $mtime,
107 'sha256' => $hash, 202 'sha256' => $hash,
108 ]; 203 ];
109} 204}
@@ -113,6 +208,16 @@ if ($base === '.' || $base === '/') {
113 $base = ''; 208 $base = '';
114} 209}
115 210
211$cachePath = getenv('ZAGAN_HASH_CACHE');
212if (!is_string($cachePath) || $cachePath === '') {
213 $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
214 . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-'
215 . substr(hash('sha256', __DIR__), 0, 16) . '.json';
216}
217$cache = load_hash_cache($cachePath);
218$cacheDirty = false;
219$seen = [];
220
116$releases = []; 221$releases = [];
117$newest = 0; 222$newest = 0;
118 223
@@ -127,10 +232,11 @@ foreach (KINDS as $kind => $meta) {
127 if ($entry === '.' || $entry === '..') { 232 if ($entry === '.' || $entry === '..') {
128 continue; 233 continue;
129 } 234 }
130 $release = read_release($kind, $directory, $entry); 235 $release = read_release($kind, $directory, $entry, $cache, $cacheDirty);
131 if ($release === null) { 236 if ($release === null) {
132 continue; 237 continue;
133 } 238 }
239 $seen[$kind . '/' . $entry] = true;
134 $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); 240 $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']);
135 $found[] = $release; 241 $found[] = $release;
136 $newest = max($newest, $release['mtime']); 242 $newest = max($newest, $release['mtime']);
@@ -156,19 +262,30 @@ foreach (KINDS as $kind => $meta) {
156 262
157 $releases[$kind] = $found; 263 $releases[$kind] = $found;
158} 264}
265
266// Drop cache entries for files that are no longer published.
267foreach (array_keys($cache['files']) as $key) {
268 if (!isset($seen[$key])) {
269 unset($cache['files'][$key]);
270 $cacheDirty = true;
271 }
272}
273if ($cacheDirty) {
274 save_hash_cache($cachePath, $cache);
275}
159?> 276?>
160<!doctype html> 277<!doctype html>
161<html lang="en"> 278<html lang="en">
162<head> 279<head>
163<meta charset="utf-8"> 280<meta charset="utf-8">
164<meta name="viewport" content="width=device-width, initial-scale=1"> 281<meta name="viewport" content="width=device-width, initial-scale=1">
165<title>Download · zagan.bet</title> 282<title>żagań.bet</title>
166<meta name="description" content="Official zagan.bet client and server downloads."> 283<meta name="description" content="Official zagan.bet client and server downloads.">
167<meta http-equiv="Content-Security-Policy" content="default-src 'none'"> 284<meta http-equiv="Content-Security-Policy" content="default-src 'none'">
168</head> 285</head>
169<body> 286<body>
170 287
171<h1>zagan.bet</h1> 288<h1>żagań.bet</h1>
172<p>Official client and server builds.</p> 289<p>Official client and server builds.</p>
173<?php if ($newest > 0): ?> 290<?php if ($newest > 0): ?>
174<p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p> 291<p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p>
@@ -181,7 +298,7 @@ foreach (KINDS as $kind => $meta) {
181<?php else: ?> 298<?php else: ?>
182<ul> 299<ul>
183<?php foreach ($releases['client'] as $release): ?> 300<?php foreach ($releases['client'] as $release): ?>
184<li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> — version <?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> 301<li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li>
185<?php endforeach; ?> 302<?php endforeach; ?>
186</ul> 303</ul>
187<?php endif; ?> 304<?php endif; ?>
@@ -193,7 +310,7 @@ foreach (KINDS as $kind => $meta) {
193<?php else: ?> 310<?php else: ?>
194<ul> 311<ul>
195<?php foreach ($releases['server'] as $release): ?> 312<?php foreach ($releases['server'] as $release): ?>
196<li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> — version <?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> 313<li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li>
197<?php endforeach; ?> 314<?php endforeach; ?>
198</ul> 315</ul>
199<?php endif; ?> 316<?php endif; ?>
@@ -222,5 +339,6 @@ echo e($sums);
222 339
223<p>Windows: run the <code>.exe</code> from Windows Terminal or another real terminal.</p> 340<p>Windows: run the <code>.exe</code> from Windows Terminal or another real terminal.</p>
224 341
342<p>When lost, execute <code>help</code> command inside zagan.bet client. Server binary supports <code>--help</code> flag</p>
225</body> 343</body>
226</html> 344</html>