diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-30 00:40:10 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-30 00:40:10 +0100 |
| commit | 79588a8bba1648ea4d396a1d1e9884af54e497e2 (patch) | |
| tree | bcc795839e1b848bb8e4c5ba017aaef6e9399ecb | |
| parent | add publish script that names release artifacts from Cargo.toml and the targe... (diff) | |
| download | zagan-bet-79588a8bba1648ea4d396a1d1e9884af54e497e2.tar.gz zagan-bet-79588a8bba1648ea4d396a1d1e9884af54e497e2.zip | |
add deploy script with live verification; cache file hashes by size and mtime
| -rwxr-xr-x | scripts/deploy.sh | 136 | ||||
| -rwxr-xr-x | scripts/publish.sh | 3 | ||||
| -rw-r--r-- | web/README.md | 28 | ||||
| -rw-r--r-- | web/index.php | 144 |
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 | ||
| 16 | set -euo pipefail | ||
| 17 | |||
| 18 | ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) | ||
| 19 | cd "$ROOT" | ||
| 20 | |||
| 21 | die() { echo "deploy: $*" >&2; exit 1; } | ||
| 22 | |||
| 23 | VERIFY_ONLY=0 | ||
| 24 | if [ "${1:-}" = "--verify-only" ]; then | ||
| 25 | VERIFY_ONLY=1 | ||
| 26 | shift | ||
| 27 | fi | ||
| 28 | |||
| 29 | TARGET=${1:-${DEPLOY_TARGET:-}} | ||
| 30 | URL=${2:-${DEPLOY_URL:-}} | ||
| 31 | if [ -z "$TARGET" ] || [ -z "$URL" ]; then | ||
| 32 | cat >&2 <<'EOF' | ||
| 33 | usage: 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 | |||
| 39 | environment: | ||
| 40 | DEPLOY_TARGET, DEPLOY_URL same as the two arguments | ||
| 41 | SSH_PORT ssh port for a remote target | ||
| 42 | EOF | ||
| 43 | exit 2 | ||
| 44 | fi | ||
| 45 | |||
| 46 | for tool in rsync curl sha256sum; do | ||
| 47 | command -v "$tool" >/dev/null || die "$tool is required" | ||
| 48 | done | ||
| 49 | |||
| 50 | [ -f web/index.php ] || die "web/index.php is missing" | ||
| 51 | |||
| 52 | shopt -s nullglob | ||
| 53 | artifacts=(web/bin/client/zagan-bet-* web/bin/server/zagan-bet-*) | ||
| 54 | shopt -u nullglob | ||
| 55 | [ ${#artifacts[@]} -gt 0 ] || die "no artifacts in web/bin; run scripts/publish.sh first" | ||
| 56 | |||
| 57 | if [ "$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 | ||
| 80 | fi | ||
| 81 | |||
| 82 | echo "==> verifying $URL" | ||
| 83 | tmp=$(mktemp -d) | ||
| 84 | trap 'rm -rf "$tmp"' EXIT | ||
| 85 | |||
| 86 | curl -fsSL --max-time 30 "$URL" -o "$tmp/page.html" \ | ||
| 87 | || die "could not fetch $URL" | ||
| 88 | |||
| 89 | origin=$(printf '%s' "$URL" | sed -E 's#^(https?://[^/]+).*#\1#') | ||
| 90 | grep -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 | |||
| 93 | failed=0 | ||
| 94 | verified=0 | ||
| 95 | for 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)) | ||
| 132 | done | ||
| 133 | |||
| 134 | [ "$failed" -eq 0 ] || die "verification failed" | ||
| 135 | |||
| 136 | 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} | |||
| 27 | publish() { | 27 | publish() { |
| 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 | |||
| 51 | publish i686-unknown-linux-gnu client linux i686 | 51 | publish i686-unknown-linux-gnu client linux i686 |
| 52 | publish x86_64-pc-windows-gnu client windows x86_64 | 52 | publish x86_64-pc-windows-gnu client windows x86_64 |
| 53 | publish x86_64-unknown-linux-gnu server linux x86_64 | 53 | publish x86_64-unknown-linux-gnu server linux x86_64 |
| 54 | publish x86_64-pc-windows-gnu server windows x86_64 | ||
| 54 | 55 | ||
| 55 | echo | 56 | echo |
| 56 | echo "published $VERSION to $DEST" | 57 | 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 | |||
| 58 | 58 | ||
| 59 | Then open <http://127.0.0.1:8899/>. | 59 | Then 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 | ||
| 69 | half-written binary, then fetches the live page and every artifact it lists and | ||
| 70 | compares the downloaded bytes with the local SHA-256. `--verify-only` skips the | ||
| 71 | upload and only checks the live site. Old versions are left on the server; | ||
| 72 | prune them by hand. | ||
| 73 | |||
| 74 | A local directory works as a target, which is how the verification itself is | ||
| 75 | tested: | ||
| 76 | |||
| 77 | ```sh | ||
| 78 | php -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 | ||
| 27 | const KINDS = [ | 28 | const 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 | */ | ||
| 81 | function 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}>} */ | ||
| 119 | function 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 | */ |
| 73 | function read_release(string $kind, string $directory, string $entry): ?array | 139 | function 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 | */ | ||
| 158 | function 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'); | ||
| 212 | if (!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. | ||
| 267 | foreach (array_keys($cache['files']) as $key) { | ||
| 268 | if (!isset($seen[$key])) { | ||
| 269 | unset($cache['files'][$key]); | ||
| 270 | $cacheDirty = true; | ||
| 271 | } | ||
| 272 | } | ||
| 273 | if ($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> |
