Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/scripts/release.sh
Fimeg a700edccc6 release.sh: origin remote + single-tag push
auto-detect gitea-local vs origin (RELEASE_REMOTE override); push only
the release tag instead of --tags, which choked on divergent legacy tags.
2026-07-12 14:54:45 -04:00

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"