clusev/docker/release/clusev-release.sh

132 lines
6.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Host side of the dashboard release bridge. `serve-request` reads run/release-request.json (the
# dashboard wrote it), cuts a release candidate — bump config version, commit, tag vX.Y.Z-rcN, push to Gitea —
# and writes a result the dashboard polls. ALL git/token operations run here; the container never
# holds credentials. `stage <target>` 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 release candidate of <target>. 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/'$//" || true)"
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}-rc*"); do
k="${t##*-rc}"
case "$k" in ''|*[!0-9]*) ;; *) [ "$k" -gt "$n" ] && n="$k" ;; esac
done
local ver="${target}-rc$((n + 1))" newtag="v${target}-rc$((n + 1))"
sed -i -E "s/('version' => ')[^']*(')/\\1${ver}\\2/" "${PROJ}/config/clusev.php"
# Opt-in SSH signing: when CLUSEV_SIGNING_KEY (path to the maintainer's SSH private key) is set in
# the release env, sign the release COMMIT (and tag) so update.sh's `verify-commit HEAD` can prove
# provenance and refuse an unsigned / attacker-substituted HEAD. Unsigned when unset — matching
# update.sh's opt-in verification (dormant until the operator provisions the keypair). The `-c`
# git-level flags go before the subcommand; the sign flags after. See the signature-verification spec.
local signkey gitc=() csign=() tsign=()
# shellcheck source=/dev/null
signkey="$( . "$GITEA_ENV" 2>/dev/null; printf '%s' "${CLUSEV_SIGNING_KEY:-}" )"
if [ -n "$signkey" ] && [ -f "$signkey" ]; then
gitc=(-c gpg.format=ssh -c "user.signingkey=${signkey}")
csign=(-S)
tsign=(-s -m "clusev ${newtag}")
fi
git -C "$PROJ" "${gitc[@]}" commit -q "${csign[@]}" -am "chore(release): ${ver}" || { echo "commit-failed" >&2; git -C "$PROJ" checkout -- . 2>/dev/null || true; return 1; }
git -C "$PROJ" "${gitc[@]}" tag "${tsign[@]}" "$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
# Atomic: the branch commit and its tag land together, or neither does. A non-atomic two-push (the
# old form) could advance origin/$BRANCH and then fail the tag push, leaving an UNTAGGED release
# commit on the remote that _rollback (reset --hard origin/$BRANCH) can no longer undo.
if ! git -C "$PROJ" push --atomic "$url" "$BRANCH" "$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 <target>" >&2; exit 2 ;;
esac