auto-detect gitea-local vs origin (RELEASE_REMOTE override); push only the release tag instead of --tags, which choked on divergent legacy tags.
261 lines
11 KiB
Shell
Executable file
261 lines
11 KiB
Shell
Executable file
#!/usr/bin/env bash
|
|
# Guided release for RedFlag. Checks everything, asks before everything.
|
|
#
|
|
# scripts/release.sh # walks you through, suggests versions
|
|
# scripts/release.sh 0.2.8.0 # same, with the target version given
|
|
#
|
|
# What it verifies before anything is tagged or pushed:
|
|
# branch == public, tree clean, local == remote, version lockstep across
|
|
# versions.go / docker-compose.yml / Cargo.toml / CHANGELOG, tag is new and
|
|
# sorts above every existing tag, Gitea is reachable, Actions is enabled,
|
|
# a runner with the right label is registered, the GITEA_TOKEN secret exists
|
|
# (offers to create it). Every mutation is shown first and confirmed.
|
|
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
# The LAN-Gitea remote is named "origin" on current checkouts, "gitea-local"
|
|
# on older ones. Take whichever exists; override with RELEASE_REMOTE=<name>.
|
|
REMOTE="${RELEASE_REMOTE:-$(git -C "$ROOT" remote | grep -qx gitea-local && echo gitea-local || echo origin)}"
|
|
|
|
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
|
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
|
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
|
|
fail() { printf ' \033[31m✗\033[0m %s\n' "$*"; }
|
|
|
|
ask() { # ask "question" -> returns 0 on yes
|
|
local reply
|
|
read -rp " → $1 [y/N] " reply
|
|
[[ "$reply" =~ ^[yY]$ ]]
|
|
}
|
|
|
|
abort() { echo; echo "Aborted. Nothing was pushed."; exit 1; }
|
|
|
|
# ---------------------------------------------------------------- step 0: gitea
|
|
bold "[0/6] Gitea connection"
|
|
|
|
REMOTE_URL=$(git -C "$ROOT" remote get-url "$REMOTE")
|
|
# http://TOKEN@host:port/Owner/Repo.git
|
|
TOKEN=$(echo "$REMOTE_URL" | sed -E 's|https?://([^@]+)@.*|\1|')
|
|
BASE=$(echo "$REMOTE_URL" | sed -E 's|(https?://)[^@]+@|\1|; s|/[^/]+/[^/]+\.git$||')
|
|
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*[:/]([^/]+/[^/]+)\.git$|\1|')
|
|
API="$BASE/api/v1"
|
|
|
|
if ! GITEA_VER=$(curl -sf --max-time 5 -H "Authorization: token $TOKEN" "$API/version" | grep -oP '"version":\s*"\K[^"]+'); then
|
|
fail "Gitea unreachable at $BASE — is the box up?"
|
|
exit 1
|
|
fi
|
|
ok "Gitea $GITEA_VER at $BASE ($OWNER_REPO)"
|
|
|
|
if [ "$(curl -s -H "Authorization: token $TOKEN" "$API/repos/$OWNER_REPO" | grep -oP '"has_actions":\s*\K(true|false)')" != "true" ]; then
|
|
fail "Actions is disabled on $OWNER_REPO — enable it in repo Settings → Units."
|
|
exit 1
|
|
fi
|
|
ok "Actions enabled on the repo"
|
|
|
|
# ------------------------------------------------------------- step 1: runners
|
|
bold "[1/6] Runner + secret preflight"
|
|
|
|
RUNNERS_JSON=$(curl -s -H "Authorization: token $TOKEN" "$API/admin/actions/runners" 2>/dev/null || echo "")
|
|
RUNNER_COUNT=$(echo "$RUNNERS_JSON" | grep -oP '"total_count":\s*\K[0-9]+' || echo 0)
|
|
if [ "${RUNNER_COUNT:-0}" -eq 0 ]; then
|
|
fail "No Actions runner is registered on this Gitea instance."
|
|
echo " A tag push will queue the release workflow forever — nothing will run it."
|
|
echo " To register one (on any docker-capable box that can reach $BASE):"
|
|
echo " 1. Get a registration token: $BASE/-/admin/actions/runners"
|
|
echo " 2. docker run -d --name act_runner --restart always \\"
|
|
echo " -v /var/run/docker.sock:/var/run/docker.sock \\"
|
|
echo " -e GITEA_INSTANCE_URL=$BASE \\"
|
|
echo " -e GITEA_RUNNER_REGISTRATION_TOKEN=<token> \\"
|
|
echo " docker.io/gitea/act_runner:latest"
|
|
echo " The default runner config carries the 'ubuntu-latest' label the workflows need."
|
|
ask "Continue anyway (tag will sit queued until a runner exists)?" || abort
|
|
else
|
|
ok "$RUNNER_COUNT runner(s) registered"
|
|
# The workflows declare runs-on labels; make sure at least one runner carries each.
|
|
NEEDED=$(grep -rhoP 'runs-on:\s*\K\S+' "$ROOT/.gitea/workflows/" | sort -u)
|
|
for label in $NEEDED; do
|
|
if echo "$RUNNERS_JSON" | grep -q "\"$label\""; then
|
|
ok "runner label '$label' available"
|
|
else
|
|
warn "no runner advertises label '$label' — jobs declaring it will never start"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if curl -s -H "Authorization: token $TOKEN" "$API/repos/$OWNER_REPO/actions/secrets" | grep -q '"GITEA_TOKEN"'; then
|
|
ok "repo secret GITEA_TOKEN exists"
|
|
else
|
|
warn "repo secret GITEA_TOKEN is missing — docker push and release creation will fail."
|
|
echo " The workflows authenticate with secrets.GITEA_TOKEN."
|
|
if ask "Create it now from the token in your $REMOTE remote?"; then
|
|
HTTP=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "$API/repos/$OWNER_REPO/actions/secrets/GITEA_TOKEN" \
|
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
-d "{\"data\":\"$TOKEN\"}")
|
|
if [ "$HTTP" = "201" ] || [ "$HTTP" = "204" ]; then
|
|
ok "secret GITEA_TOKEN created"
|
|
else
|
|
fail "secret creation returned HTTP $HTTP"
|
|
abort
|
|
fi
|
|
else
|
|
ask "Continue without it (release job WILL fail at docker push)?" || abort
|
|
fi
|
|
fi
|
|
|
|
# ------------------------------------------------------------ step 2: git state
|
|
bold "[2/6] Git state"
|
|
|
|
BRANCH=$(git -C "$ROOT" branch --show-current)
|
|
if [ "$BRANCH" != "public" ]; then
|
|
fail "On branch '$BRANCH' — releases come from public."
|
|
abort
|
|
fi
|
|
ok "on public"
|
|
|
|
git -C "$ROOT" fetch "$REMOTE" public --tags --quiet
|
|
AHEAD=$(git -C "$ROOT" rev-list --count "$REMOTE/public..public")
|
|
BEHIND=$(git -C "$ROOT" rev-list --count "public..$REMOTE/public")
|
|
[ "$BEHIND" -gt 0 ] && { fail "public is $BEHIND commit(s) behind $REMOTE/public — pull/rebase first."; abort; }
|
|
[ "$AHEAD" -gt 0 ] && warn "public is $AHEAD commit(s) ahead of $REMOTE/public (they push with the tag)"
|
|
[ "$AHEAD" -eq 0 ] && ok "public matches $REMOTE/public"
|
|
|
|
if ! git -C "$ROOT" diff --quiet || ! git -C "$ROOT" diff --cached --quiet; then
|
|
warn "uncommitted changes:"
|
|
git -C "$ROOT" status --short | sed 's/^/ /' | head -20
|
|
UNTRACKED=$(git -C "$ROOT" status --short | grep -c '^??' || true)
|
|
[ "$UNTRACKED" -gt 0 ] && warn "$UNTRACKED untracked file(s) above will NOT be in the release unless added"
|
|
if ask "Commit everything (git add -A) before tagging?"; then
|
|
read -rp " → Commit message: " MSG
|
|
[ -z "$MSG" ] && { fail "empty commit message"; abort; }
|
|
git -C "$ROOT" add -A
|
|
git -C "$ROOT" commit -m "$MSG"
|
|
ok "committed: $MSG"
|
|
else
|
|
ask "Tag and release WITHOUT the uncommitted changes?" || abort
|
|
fi
|
|
else
|
|
ok "working tree clean"
|
|
fi
|
|
|
|
# -------------------------------------------------------------- step 3: version
|
|
bold "[3/6] Version lockstep"
|
|
|
|
V_SERVER=$(grep -P '^\s*AgentVersion\s*=' "$ROOT/server/internal/version/versions.go" | grep -oP '"\K[^"]+')
|
|
V_CONFIG=$(grep -P '^\s*ConfigVersion\s*=' "$ROOT/server/internal/version/versions.go" | grep -oP '"\K[^"]+')
|
|
V_COMPOSE=$(grep -oP '(?<=BUILD_VERSION:-)[0-9]+(\.[0-9]+){3}' "$ROOT/docker-compose.yml")
|
|
V_CARGO=$(grep -m1 '^version' "$ROOT/helper/Cargo.toml" | cut -d'"' -f2)
|
|
V_TAG=$(git -C "$ROOT" tag --list 'v*' --sort=-v:refname | head -1 | sed 's/^v//')
|
|
|
|
echo " versions.go (Agent): $V_SERVER"
|
|
echo " versions.go (Config): $V_CONFIG"
|
|
echo " docker-compose.yml: $V_COMPOSE"
|
|
echo " helper/Cargo.toml: $V_CARGO"
|
|
echo " highest existing tag: ${V_TAG:-none}"
|
|
|
|
NEW_VERSION="${1:-}"
|
|
if [ -z "$NEW_VERSION" ]; then
|
|
if [ "$V_SERVER" = "$V_COMPOSE" ] && [ "$V_SERVER" != "${V_TAG:-}" ]; then
|
|
echo
|
|
echo " Tree is already bumped to $V_SERVER (no tag for it yet)."
|
|
ask "Release v$V_SERVER?" && NEW_VERSION="$V_SERVER"
|
|
fi
|
|
if [ -z "$NEW_VERSION" ]; then
|
|
IFS='.' read -r a b c d <<< "$V_COMPOSE"
|
|
echo
|
|
echo " Suggested bumps from $V_COMPOSE:"
|
|
echo " patch: $a.$b.$c.$((d+1))"
|
|
echo " minor: $a.$b.$((c+1)).0"
|
|
echo " major: $a.$((b+1)).0.0"
|
|
read -rp " → New version: " NEW_VERSION
|
|
fi
|
|
fi
|
|
|
|
[[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || { fail "version must be N.N.N.N (got: $NEW_VERSION)"; abort; }
|
|
|
|
# Bump the tree if it isn't already at the target.
|
|
if [ "$V_SERVER" != "$NEW_VERSION" ] || [ "$V_COMPOSE" != "$NEW_VERSION" ]; then
|
|
echo
|
|
warn "tree is not at $NEW_VERSION — running bump-version.sh"
|
|
"$ROOT/scripts/bump-version.sh" "$NEW_VERSION"
|
|
if ask "Commit the bump as 'v$NEW_VERSION'?"; then
|
|
git -C "$ROOT" add -A
|
|
git -C "$ROOT" commit -m "v$NEW_VERSION"
|
|
ok "committed v$NEW_VERSION"
|
|
else
|
|
fail "bump is uncommitted — the tag would point at a tree without it."
|
|
abort
|
|
fi
|
|
fi
|
|
|
|
# Same checks the CI gate runs — catch it here, not after the push.
|
|
LOCKSTEP_FAIL=0
|
|
for pair in "AgentVersion=$(grep -P '^\s*AgentVersion\s*=' "$ROOT/server/internal/version/versions.go" | grep -oP '"\K[^"]+')" \
|
|
"ConfigVersion=$(grep -P '^\s*ConfigVersion\s*=' "$ROOT/server/internal/version/versions.go" | grep -oP '"\K[^"]+')" \
|
|
"docker-compose=$(grep -oP '(?<=BUILD_VERSION:-)[0-9]+(\.[0-9]+){3}' "$ROOT/docker-compose.yml")"; do
|
|
name="${pair%%=*}"; val="${pair#*=}"
|
|
if [ "$val" != "$NEW_VERSION" ]; then fail "$name is $val, expected $NEW_VERSION"; LOCKSTEP_FAIL=1; fi
|
|
done
|
|
CARGO_NOW=$(grep -m1 '^version' "$ROOT/helper/Cargo.toml" | cut -d'"' -f2)
|
|
[ "$CARGO_NOW" != "$(echo "$NEW_VERSION" | cut -d. -f1-3)" ] && { fail "Cargo.toml is $CARGO_NOW, expected $(echo "$NEW_VERSION" | cut -d. -f1-3)"; LOCKSTEP_FAIL=1; }
|
|
[ "$LOCKSTEP_FAIL" -eq 1 ] && abort
|
|
ok "all version sources agree on $NEW_VERSION"
|
|
|
|
if git -C "$ROOT" rev-parse "v$NEW_VERSION" >/dev/null 2>&1; then
|
|
fail "tag v$NEW_VERSION already exists"
|
|
abort
|
|
fi
|
|
HIGHEST=$(printf 'v%s\nv%s\n' "${V_TAG:-0.0.0.0}" "$NEW_VERSION" | sort -V | tail -1)
|
|
[ "$HIGHEST" != "v$NEW_VERSION" ] && { fail "v$NEW_VERSION does not sort above v$V_TAG — versions move forward only"; abort; }
|
|
ok "v$NEW_VERSION is new and sorts above v${V_TAG:-none}"
|
|
|
|
# ------------------------------------------------------------ step 4: changelog
|
|
bold "[4/6] CHANGELOG"
|
|
|
|
if grep -q "$NEW_VERSION" "$ROOT/CHANGELOG.md" 2>/dev/null; then
|
|
ok "CHANGELOG.md has an entry for $NEW_VERSION"
|
|
else
|
|
fail "no CHANGELOG.md entry for $NEW_VERSION — the CI gate will reject the tag."
|
|
echo " Add the entry, commit it, and rerun. (This script stops here on purpose:"
|
|
echo " a release without release notes is the 'what's new?' gap.)"
|
|
abort
|
|
fi
|
|
|
|
# ----------------------------------------------------------- step 5: tag + push
|
|
bold "[5/6] Tag and push"
|
|
|
|
echo " About to run:"
|
|
echo " git tag v$NEW_VERSION"
|
|
echo " git push $REMOTE public v$NEW_VERSION"
|
|
echo " The tag push triggers the release workflow: gate → build → verify → publish."
|
|
ask "Proceed?" || abort
|
|
|
|
git -C "$ROOT" tag "v$NEW_VERSION"
|
|
# Push only this release's tag. --tags would ship every local tag and chokes
|
|
# on this checkout's divergent legacy tags (v0.2.8.2 had to be cut by hand).
|
|
git -C "$ROOT" push "$REMOTE" public "v$NEW_VERSION"
|
|
ok "pushed public + v$NEW_VERSION to $REMOTE"
|
|
|
|
# ------------------------------------------------------------- step 6: watch CI
|
|
bold "[6/6] Watching the workflow"
|
|
|
|
echo " Polling for the release run (90s max) ..."
|
|
for i in $(seq 1 15); do
|
|
sleep 6
|
|
RUNS=$(curl -s -H "Authorization: token $TOKEN" "$API/repos/$OWNER_REPO/actions/tasks?limit=5")
|
|
STATUS=$(echo "$RUNS" | grep -oP '"status":\s*"\K[^"]+' | head -1 || true)
|
|
NAME=$(echo "$RUNS" | grep -oP '"name":\s*"\K[^"]+' | head -1 || true)
|
|
if [ -n "$STATUS" ]; then
|
|
echo " run '$NAME' → $STATUS"
|
|
case "$STATUS" in
|
|
success) ok "release pipeline finished"; break ;;
|
|
failure) fail "pipeline failed — see $BASE/$OWNER_REPO/actions"; exit 1 ;;
|
|
esac
|
|
else
|
|
echo " no run picked up yet (waiting on a runner?)"
|
|
fi
|
|
done
|
|
|
|
echo
|
|
echo "Done. Watch it live: $BASE/$OWNER_REPO/actions"
|
|
echo "Release lands at: $BASE/$OWNER_REPO/releases"
|