mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
276 lines
11 KiB
Bash
Executable File
276 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
# Build a deterministic, runtime-only Wowngeon release artifact from one clean Git commit.
|
|
# This script deliberately does not install dependencies, read environment files, contact a
|
|
# deployment host, or activate a release. See --help and docs/DEPLOY.md for the operator gates.
|
|
|
|
umask 077
|
|
|
|
die() {
|
|
printf 'build-release-artifact: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: scripts/deploy/build-release-artifact.sh [options]
|
|
|
|
Options:
|
|
--ref REF Git commit-ish to archive (default: HEAD)
|
|
--release-id ID Destination directory name inside the archive
|
|
(default: git-<12-character commit prefix>)
|
|
--output-dir DIR Existing or new non-symlink output directory
|
|
(default: <repository>/dist/releases)
|
|
-h, --help Show this help
|
|
|
|
The builder fails unless tracked files and non-ignored untracked files are clean. It archives only
|
|
the runtime allowlist (LICENSE, html/**, and the guarded runtime subset of src/**) from the selected
|
|
commit, rejects symlinks/gitlinks and non-example .env files anywhere in that commit, and refuses
|
|
to overwrite an artifact or checksum. Repeating the command for the same commit, release ID, and
|
|
toolchain produces byte-identical .tar.gz output.
|
|
EOF
|
|
}
|
|
|
|
require_command() {
|
|
command -v "$1" >/dev/null 2>&1 || die "required command is unavailable: $1"
|
|
}
|
|
|
|
for required_command in git tar gzip sha256sum mktemp install ln rm chmod cat dirname basename find sort xargs node; do
|
|
require_command "$required_command"
|
|
done
|
|
|
|
script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
|
repo_root=$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null) \
|
|
|| die 'script is not inside a Git worktree'
|
|
repo_root=$(CDPATH= cd -- "$repo_root" && pwd -P)
|
|
|
|
ref=HEAD
|
|
release_id=''
|
|
output_dir="$repo_root/dist/releases"
|
|
|
|
while (($# > 0)); do
|
|
case "$1" in
|
|
--ref)
|
|
(($# >= 2)) || die '--ref requires a value'
|
|
ref=$2
|
|
shift 2
|
|
;;
|
|
--release-id)
|
|
(($# >= 2)) || die '--release-id requires a value'
|
|
release_id=$2
|
|
shift 2
|
|
;;
|
|
--output-dir)
|
|
(($# >= 2)) || die '--output-dir requires a value'
|
|
output_dir=$2
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
die "unknown argument: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# A commit name cannot describe uncommitted candidate content. Refuse both tracked changes and
|
|
# non-ignored untracked additions instead of silently producing an artifact different from the
|
|
# reviewed tree. Ignored files such as local .env and node_modules are never read or archived.
|
|
if [[ -n "$(git -C "$repo_root" status --porcelain=v1 --untracked-files=all)" ]]; then
|
|
die 'worktree is dirty; commit or remove every tracked/untracked candidate change first'
|
|
fi
|
|
|
|
commit=$(git -C "$repo_root" rev-parse --verify "${ref}^{commit}" 2>/dev/null) \
|
|
|| die "ref does not resolve to a commit: $ref"
|
|
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || die 'resolved commit is not a full SHA-1 object ID'
|
|
|
|
if [[ -z "$release_id" ]]; then
|
|
release_id="git-${commit:0:12}"
|
|
fi
|
|
[[ "$release_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$ ]] \
|
|
|| die 'release ID must be 1-96 safe characters and start with an alphanumeric character'
|
|
[[ "$release_id" != '.' && "$release_id" != '..' ]] || die 'release ID cannot be dot or dot-dot'
|
|
|
|
if [[ -e "$output_dir" || -L "$output_dir" ]]; then
|
|
[[ -d "$output_dir" && ! -L "$output_dir" ]] \
|
|
|| die 'output directory must be a real directory, not a symlink'
|
|
else
|
|
install -d -m 0700 -- "$output_dir"
|
|
fi
|
|
output_dir=$(CDPATH= cd -- "$output_dir" && pwd -P)
|
|
|
|
artifact="$output_dir/${release_id}.tar.gz"
|
|
checksum="$artifact.sha256"
|
|
[[ ! -e "$artifact" && ! -L "$artifact" ]] || die "refusing to overwrite: $artifact"
|
|
[[ ! -e "$checksum" && ! -L "$checksum" ]] || die "refusing to overwrite: $checksum"
|
|
|
|
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/wowngeon-release.XXXXXXXX")
|
|
artifact_tmp=$(mktemp "$output_dir/.${release_id}.artifact.XXXXXXXX")
|
|
checksum_tmp=$(mktemp "$output_dir/.${release_id}.checksum.XXXXXXXX")
|
|
published=0
|
|
cleanup() {
|
|
local status=$?
|
|
if [[ "$published" != '1' ]]; then
|
|
# Another same-ID builder may have won publication after both processes passed the
|
|
# initial existence check. Remove a destination only while it is still our own hard link;
|
|
# a losing builder must never unlink the winning builder's artifact or checksum.
|
|
if [[ -e "$artifact" && "$artifact" -ef "$artifact_tmp" ]]; then
|
|
rm -f -- "$artifact"
|
|
fi
|
|
if [[ -e "$checksum" && "$checksum" -ef "$checksum_tmp" ]]; then
|
|
rm -f -- "$checksum"
|
|
fi
|
|
fi
|
|
rm -f -- "$artifact_tmp" "$checksum_tmp"
|
|
rm -rf -- "$work_dir"
|
|
return "$status"
|
|
}
|
|
trap cleanup EXIT
|
|
trap 'exit 129' HUP
|
|
trap 'exit 130' INT
|
|
trap 'exit 143' TERM
|
|
|
|
source_tar="$work_dir/runtime-source.tar"
|
|
stage_root="$work_dir/stage"
|
|
release_root="$stage_root/$release_id"
|
|
install -d -m 0700 -- "$release_root"
|
|
|
|
# Git trees can encode regular files, executables, symlinks, and gitlinks. Releases accept only
|
|
# the first two. This also validates path policy before tar is allowed to extract anything.
|
|
while IFS= read -r -d '' tree_entry; do
|
|
metadata=${tree_entry%%$'\t'*}
|
|
path=${tree_entry#*$'\t'}
|
|
mode=${metadata%% *}
|
|
|
|
[[ "$metadata" != "$path" ]] || die 'malformed Git tree entry'
|
|
[[ "$mode" == '100644' || "$mode" == '100755' ]] \
|
|
|| die "tracked symlink, gitlink, or unsafe file mode is forbidden: $path"
|
|
[[ "$path" != *$'\n'* && "$path" != *$'\r'* ]] \
|
|
|| die 'tracked filenames containing newline or carriage return are forbidden'
|
|
[[ "$path" != /* && "$path" != ../* && "$path" != */../* && "$path" != */.. ]] \
|
|
|| die "unsafe tracked path: $path"
|
|
[[ "/$path/" != *'/.git/'* && "/$path/" != *'/node_modules/'* ]] \
|
|
|| die "tracked VCS/dependency state is forbidden: $path"
|
|
|
|
filename=$(basename -- "$path")
|
|
case "$filename" in
|
|
.env)
|
|
die "tracked runtime environment file is forbidden: $path"
|
|
;;
|
|
.env.*)
|
|
[[ "$filename" == *.example ]] \
|
|
|| die "tracked non-example environment file is forbidden: $path"
|
|
;;
|
|
.npmrc|.yarnrc|.netrc)
|
|
die "tracked credential-capable configuration file is forbidden: $path"
|
|
;;
|
|
esac
|
|
done < <(git -C "$repo_root" ls-tree -rz --full-tree "$commit")
|
|
|
|
# Production releases intentionally exclude source documentation, tests, root-level operator
|
|
# tooling/service units, and environment examples. Operational artifacts remain in the reviewed
|
|
# source/fleet repositories and must have their own pinned provenance; they are never made part of
|
|
# the application runtime merely because they share a Git commit.
|
|
runtime_pathspecs=(
|
|
'LICENSE'
|
|
'html'
|
|
'src'
|
|
':(exclude)src/.env*'
|
|
':(glob,exclude)src/**/.env*'
|
|
':(exclude)src/scripts/pvp-capture.js'
|
|
':(exclude)src/scripts/stagenet-financial-canary.js'
|
|
':(exclude)src/scripts/setup_db.js'
|
|
':(exclude)src/scripts/smoke.js'
|
|
':(exclude)src/jest.config.js'
|
|
':(exclude)src/sim'
|
|
)
|
|
git -C "$repo_root" archive --format=tar "$commit" -- "${runtime_pathspecs[@]}" >"$source_tar"
|
|
source_hash=$(sha256sum "$source_tar")
|
|
source_hash=${source_hash%% *}
|
|
[[ "$source_hash" =~ ^[0-9a-f]{64}$ ]] || die 'could not calculate source archive SHA-256'
|
|
|
|
tar --extract --file="$source_tar" --directory="$release_root" --no-same-owner --no-same-permissions
|
|
|
|
runtime_roots=$(find "$release_root" -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort)
|
|
[[ "$runtime_roots" == $'LICENSE\nhtml\nsrc' ]] \
|
|
|| die 'runtime archive contains an unexpected or missing top-level path'
|
|
[[ -z "$(find "$release_root/src" -type f -name '.env*' -print -quit)" ]] \
|
|
|| die 'runtime archive contains a forbidden environment file or example'
|
|
|
|
for required_file in LICENSE src/index.js src/package.json src/package-lock.json \
|
|
src/scripts/preflight.js src/scripts/migrate-disposable-clone.js html/index.html; do
|
|
[[ -f "$release_root/$required_file" && ! -L "$release_root/$required_file" ]] \
|
|
|| die "required release file is absent or unsafe: $required_file"
|
|
done
|
|
|
|
# Keep the source package manifest useful to developers while exposing only runnable commands in
|
|
# the runtime artifact. This deterministic metadata-only rewrite does not change dependencies or
|
|
# lockfile resolution; the final runtime bytes are covered by .release-files.sha256 below.
|
|
node - "$release_root/src/package.json" <<'NODE'
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const manifestPath = process.argv[2];
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
const sourceScripts = manifest.scripts && typeof manifest.scripts === 'object'
|
|
? manifest.scripts
|
|
: {};
|
|
const runtimeScripts = {};
|
|
for (const name of ['start', 'preflight', 'db:migrate:clone']) {
|
|
if (typeof sourceScripts[name] !== 'string' || sourceScripts[name].trim().length === 0) {
|
|
throw new Error(`required runtime package script is absent or empty: ${name}`);
|
|
}
|
|
runtimeScripts[name] = sourceScripts[name];
|
|
}
|
|
manifest.scripts = runtimeScripts;
|
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 });
|
|
NODE
|
|
|
|
lock_hash=$(sha256sum "$release_root/src/package-lock.json")
|
|
lock_hash=${lock_hash%% *}
|
|
commit_epoch=$(git -C "$repo_root" show -s --format=%ct "$commit")
|
|
[[ "$commit_epoch" =~ ^[0-9]+$ ]] || die 'commit timestamp is invalid'
|
|
|
|
printf '%s\n' "$source_hash" >"$release_root/.release-source-sha256"
|
|
printf '%s\n' "$commit" >"$release_root/.release-commit"
|
|
printf '%s\n' "$release_id" >"$release_root/.release-id"
|
|
printf '%s\n' "$lock_hash" >"$release_root/.release-package-lock-sha256"
|
|
(
|
|
cd "$release_root"
|
|
find . -type f ! -path './.release-files.sha256' -print0 \
|
|
| LC_ALL=C sort -z \
|
|
| xargs -0 -r sha256sum --
|
|
) >"$release_root/.release-files.sha256"
|
|
|
|
# Normalize ownership, modes, ordering, timestamps, and gzip headers. Metadata files deliberately
|
|
# identify the prefix-free, runtime-allowlisted `git archive` hash; the sidecar identifies the
|
|
# final blob.
|
|
tar \
|
|
--sort=name \
|
|
--format=posix \
|
|
--pax-option=delete=atime,delete=ctime \
|
|
--mtime="@$commit_epoch" \
|
|
--owner=0 --group=0 --numeric-owner \
|
|
--mode='u+rwX,go+rX,go-w' \
|
|
--directory="$stage_root" \
|
|
--create --file=- "$release_id" \
|
|
| gzip -n >"$artifact_tmp"
|
|
|
|
artifact_hash=$(sha256sum "$artifact_tmp")
|
|
artifact_hash=${artifact_hash%% *}
|
|
printf '%s %s\n' "$artifact_hash" "$(basename -- "$artifact")" >"$checksum_tmp"
|
|
|
|
# Hard-link publication is atomic and refuses an existing destination even under a concurrent run.
|
|
ln -- "$artifact_tmp" "$artifact" || die "could not publish artifact without overwrite: $artifact"
|
|
if ! ln -- "$checksum_tmp" "$checksum"; then
|
|
rm -f -- "$artifact"
|
|
die "could not publish checksum without overwrite: $checksum"
|
|
fi
|
|
chmod 0600 -- "$artifact" "$checksum"
|
|
published=1
|
|
|
|
printf 'release_id=%s\ncommit=%s\nsource_sha256=%s\nartifact_sha256=%s\nartifact=%s\nchecksum=%s\n' \
|
|
"$release_id" "$commit" "$source_hash" "$artifact_hash" "$artifact" "$checksum"
|