diff --git a/docker/release/clusev-release.sh b/docker/release/clusev-release.sh new file mode 100755 index 0000000..1f42035 --- /dev/null +++ b/docker/release/clusev-release.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Host side of the dashboard release bridge. `serve-request` reads run/release-request.json (the +# dashboard wrote it), cuts a beta — bump config version, commit, tag vX.Y.Z-betaN, push to Gitea — +# and writes a result the dashboard polls. ALL git/token operations run here; the container never +# holds credentials. `stage ` is the same path without the request file (used by the test). +set -euo pipefail + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJ="${CLUSEV_DIR:-$(cd "${SELF_DIR}/../.." && pwd)}" +RUN_DIR="${PROJ}/run" +BRANCH="${CLUSEV_RELEASE_BRANCH:-feat/v1-foundation}" +GITEA_ENV="${CLUSEV_GITEA_ENV:-/home/nexxo/.env.gitea}" + +_json_str() { + local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; s="${s//$'\r'/}"; s="${s//$'\t'/\\t}"; s="${s//$'\n'/\\n}" + printf '"%s"' "$s" +} + +_write_result() { # id ok tag message + local id="$1" ok="$2" tag="$3" msg="$4" f="${RUN_DIR}/release-result-$1.json" + mkdir -p "$RUN_DIR" 2>/dev/null || true + printf '{"id":"%s","ok":%s,"tag":%s,"message":%s,"at":%s}\n' \ + "$id" "$ok" "$(_json_str "$tag")" "$(_json_str "$msg")" "$(date +%s 2>/dev/null || echo 0)" > "${f}.tmp" + mv -f "${f}.tmp" "$f" 2>/dev/null || { rm -f "${f}.tmp"; return 0; } + chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$f" 2>/dev/null || true + chmod 600 "$f" 2>/dev/null || true +} + +# numeric semver "$1 >= $2" +_ver_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]; } + +_rollback() { # tag + git -C "$PROJ" tag -d "$1" >/dev/null 2>&1 || true + git -C "$PROJ" reset --hard "origin/${BRANCH}" >/dev/null 2>&1 || true +} + +# Preconditions. Echoes a one-word reason + returns non-zero on failure; silent + 0 on success. +_precheck() { + git -C "$PROJ" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "no-repo"; return 1; } + [ "$(git -C "$PROJ" rev-parse --abbrev-ref HEAD)" = "$BRANCH" ] || { echo "wrong-branch"; return 1; } + [ -z "$(git -C "$PROJ" status --porcelain)" ] || { echo "dirty-tree"; return 1; } + git -C "$PROJ" fetch origin "$BRANCH" >/dev/null 2>&1 || { echo "fetch-failed"; return 1; } + [ "$(git -C "$PROJ" rev-parse HEAD)" = "$(git -C "$PROJ" rev-parse "origin/${BRANCH}")" ] || { echo "unpushed"; return 1; } +} + +# Cut + push a beta of . Echoes the new tag on success; reason on stderr + non-zero on failure. +_do_stage() { # target + local target="$1" cur base + cur="$(grep -oE "'version' => '[^']*'" "${PROJ}/config/clusev.php" | head -n1 | sed -E "s/.*=> '//; s/'$//")" + base="${cur%%-*}" + _ver_ge "$target" "$base" || { echo "downgrade-rejected" >&2; return 1; } + + local n=0 t k + for t in $(git -C "$PROJ" tag -l "v${target}-beta*"); do + k="${t##*-beta}" + case "$k" in ''|*[!0-9]*) ;; *) [ "$k" -gt "$n" ] && n="$k" ;; esac + done + local ver="${target}-beta$((n + 1))" newtag="v${target}-beta$((n + 1))" + + sed -i -E "s/('version' => ')[^']*(')/\\1${ver}\\2/" "${PROJ}/config/clusev.php" + git -C "$PROJ" commit -q -am "chore(release): ${ver}" || { echo "commit-failed" >&2; git -C "$PROJ" checkout -- . 2>/dev/null || true; return 1; } + git -C "$PROJ" tag "$newtag" || { echo "tag-failed" >&2; _rollback "$newtag"; return 1; } + + local token origin host url + # shellcheck source=/dev/null + token="$( . "$GITEA_ENV" 2>/dev/null; printf '%s' "${GIT_ACCESS_TOKEN:-${GITEA_TOKEN:-}}" )" + origin="$(git -C "$PROJ" remote get-url origin)" + case "$origin" in + https://*) + [ -n "$token" ] || { echo "no-token" >&2; _rollback "$newtag"; return 1; } + host="$(printf '%s' "$origin" | sed -E 's#^https://([^/]*@)?##')" + url="https://oauth2:${token}@${host}" ;; + *) url="$origin" ;; # local/file remote (tests): push directly + esac + + if ! git -C "$PROJ" push "$url" "$BRANCH" >/dev/null 2>&1 || ! git -C "$PROJ" push "$url" "$newtag" >/dev/null 2>&1; then + echo "push-failed" >&2; _rollback "$newtag"; return 1 + fi + printf '%s' "$newtag" +} + +cmd_serve_request() { + local req="${RUN_DIR}/release-request.json" wf="${RUN_DIR}/release-request.processing" + [ -f "$req" ] || return 0 + mv -f "$req" "$wf" 2>/dev/null || return 0 + local json id action target + json="$(cat "$wf" 2>/dev/null || true)"; rm -f "$wf" + id="$(printf '%s' "$json" | grep -oE '"id":"[A-Za-z0-9]{16,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)" + [ -n "$id" ] || return 0 + action="$(printf '%s' "$json" | grep -oE '"action":"[a-z-]{1,16}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)" + target="$(printf '%s' "$json" | grep -oE '"target":"[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)" + + local ok=false tag='' msg='ok' reason errf + errf="$(mktemp)" + case "$action" in + stage) + if [ -z "$target" ]; then msg='invalid-target' + elif ! reason="$(_precheck 2>&1)"; then msg="$reason" + elif tag="$(_do_stage "$target" 2>"$errf")"; then ok=true; msg='ok' + else msg="$(cat "$errf" 2>/dev/null || echo 'release-failed')"; tag='' ; fi ;; + *) msg='unknown-action' ;; + esac + rm -f "$errf" + _write_result "$id" "$ok" "$tag" "$msg" +} + +case "${1:-}" in + serve-request) cmd_serve_request ;; + stage) + target="${2:?target required}" + if reason="$(_precheck 2>&1)"; then _do_stage "$target"; else printf '%s\n' "$reason" >&2; exit 1; fi ;; + *) echo "usage: $0 serve-request|stage " >&2; exit 2 ;; +esac diff --git a/docker/release/serve-request.test.sh b/docker/release/serve-request.test.sh new file mode 100644 index 0000000..2cace9f --- /dev/null +++ b/docker/release/serve-request.test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Test clusev-release.sh `stage`: bump config version, commit, tag vX.Y.Z-betaN, push to a (local) +# Gitea-stand-in remote; beta auto-increment; refusals (dirty / unpushed / downgrade); push-fail rollback. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t + +# bare "Gitea" remote + a working clone with a config/clusev.php on branch main +git init -q --bare "$work/remote.git" +git clone -q "$work/remote.git" "$work/proj" +git -C "$work/proj" checkout -q -b main +mkdir -p "$work/proj/config" +printf " '0.9.58',\n];\n" > "$work/proj/config/clusev.php" +git -C "$work/proj" add -A && git -C "$work/proj" commit -qm init && git -C "$work/proj" push -q -u origin main + +printf 'GIT_ACCESS_TOKEN=dummy\n' > "$work/.env.gitea" +run() { CLUSEV_DIR="$work/proj" CLUSEV_RELEASE_BRANCH=main CLUSEV_GITEA_ENV="$work/.env.gitea" bash "$here/clusev-release.sh" "$@"; } + +# happy path: minor -> 0.10.0-beta1, config bumped, tag pushed to the remote +tag="$(run stage 0.10.0)" +test "$tag" = "v0.10.0-beta1" || { echo "FAIL: expected v0.10.0-beta1, got '$tag'"; exit 1; } +grep -q "'version' => '0.10.0-beta1'" "$work/proj/config/clusev.php" || { echo "FAIL: config not bumped"; exit 1; } +git -C "$work/remote.git" tag | grep -qx 'v0.10.0-beta1' || { echo "FAIL: tag not pushed to remote"; exit 1; } + +# second run, same target -> beta2 +tag2="$(run stage 0.10.0)" +test "$tag2" = "v0.10.0-beta2" || { echo "FAIL: expected beta2, got '$tag2'"; exit 1; } + +# downgrade rejected (target below the current base 0.10.0) +if run stage 0.9.0 2>/dev/null; then echo "FAIL: downgrade not rejected"; exit 1; fi + +# dirty tree rejected +echo dirt > "$work/proj/dirt.txt" +if run stage 0.11.0 2>/dev/null; then echo "FAIL: dirty tree not rejected"; exit 1; fi +rm -f "$work/proj/dirt.txt" + +# unpushed commit rejected +echo x >> "$work/proj/config/clusev.php"; git -C "$work/proj" commit -qam wip +if run stage 0.11.0 2>/dev/null; then echo "FAIL: unpushed commit not rejected"; exit 1; fi +git -C "$work/proj" reset -q --hard origin/main + +# push-fail rollback: point origin at an unreachable https remote, attempt, assert rollback +git -C "$work/proj" remote set-url origin https://127.0.0.1:1/x.git +before="$(git -C "$work/proj" rev-parse HEAD)" +if run stage 0.11.0 2>/dev/null; then echo "FAIL: push to dead remote should fail"; exit 1; fi +git -C "$work/proj" remote set-url origin "$work/remote.git" +git -C "$work/proj" tag | grep -qx 'v0.11.0-beta1' && { echo "FAIL: tag not rolled back"; exit 1; } +test "$(git -C "$work/proj" rev-parse HEAD)" = "$before" || { echo "FAIL: HEAD not rolled back"; exit 1; } + +echo "PASS"