RedFlag/scripts/release.sh
Fimeg 67e26be2d9 projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally.

Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
2026-09-08 21:59:33 -04:00

306 lines
13 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 public-forge publisher
# secret exists, and the dedicated SSH release key can sign and verify.
# 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)}"
# The API address is installation-local. Keep it in the environment or this
# checkout's config; neither belongs in reachable public history.
GITEA="${GITEA_URL:-$(git -C "$ROOT" config --get redflag.giteaUrl || true)}"
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; }
[ -n "$GITEA" ] || {
fail "No Gitea API URL (set GITEA_URL or git config redflag.giteaUrl)"
exit 1
}
# ---------------------------------------------------------------- step 0: gitea
bold "[0/6] Gitea connection"
REMOTE_URL=$(git -C "$ROOT" remote get-url "$REMOTE")
# Git transport and API authentication are separate. The current remote is
# SSH, while Actions lives on the LAN HTTP endpoint. Ask git's credential
# helper for the API token instead of assuming a secret is embedded in a URL.
credential_protocol=${GITEA%%://*}
credential_host=${GITEA#*://}
credential_host=${credential_host%%/*}
credential=
TOKEN=${GITEA_TOKEN:-}
if [ -z "$TOKEN" ]; then
credential=$(printf 'protocol=%s\nhost=%s\n\n' \
"$credential_protocol" "$credential_host" \
| GIT_TERMINAL_PROMPT=0 git credential fill 2>/dev/null) || credential=
TOKEN=$(printf '%s\n' "$credential" | sed -n 's/^password=//p' | head -1)
fi
if [ -z "$TOKEN" ]; then
TOKEN=$(printf '%s\n' "$credential" \
| sed -n 's/^username=\([0-9a-f]\{40\}\)$/\1/p' | head -1)
fi
[ -n "$TOKEN" ] || { fail "No API token for $GITEA (set GITEA_TOKEN or configure git credential)"; exit 1; }
case "$REMOTE_URL" in
http://*|https://*|ssh://*) remote_path=${REMOTE_URL#*://}; remote_path=${remote_path#*/} ;;
*:*) remote_path=${REMOTE_URL#*:} ;;
*) remote_path=$REMOTE_URL ;;
esac
OWNER_REPO=${remote_path%.git}
OWNER_REPO=${OWNER_REPO#/}
API="$GITEA/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 $GITEA — is the box up?"
exit 1
fi
ok "Gitea $GITEA_VER at $GITEA ($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 $GITEA):"
echo " 1. Get a registration token: $GITEA/-/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=$GITEA \\"
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 '"PUBLIC_FORGE_TOKEN"'; then
ok "repo secret PUBLIC_FORGE_TOKEN exists"
else
fail "repo secret PUBLIC_FORGE_TOKEN is missing — public tag and release publication will fail."
echo " Install the dedicated public-forge publisher token in repo Actions secrets."
exit 1
fi
if [ "$(git -C "$ROOT" config --get gpg.format)" != "ssh" ]; then
fail "git gpg.format must be ssh for RedFlag release tags"
exit 1
fi
SIGNING_KEY=$(git -C "$ROOT" config --get user.signingkey || true)
[ -n "$SIGNING_KEY" ] && [ -r "$SIGNING_KEY" ] || { fail "configured release signing key is missing or unreadable"; exit 1; }
[ -r "$ROOT/.gitea/allowed_signers" ] || { fail "release allowed-signers file is missing"; exit 1; }
ok "dedicated SSH release signing key is available"
# ------------------------------------------------------------ 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 -s -m 'v$NEW_VERSION' 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 -s -m "v$NEW_VERSION" "v$NEW_VERSION"
git -C "$ROOT" config gpg.ssh.allowedSignersFile .gitea/allowed_signers
git -C "$ROOT" tag -v "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"
RELEASE_SHA=$(git -C "$ROOT" rev-parse "v$NEW_VERSION^{commit}")
# ------------------------------------------------------------- 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
if ! RUNS=$(curl -fsS -H "Authorization: token $TOKEN" "$API/repos/$OWNER_REPO/actions/runs?limit=20"); then
fail "Actions API became unreachable while watching v$NEW_VERSION"
exit 2
fi
RUN=$(printf '%s' "$RUNS" | python3 -c '
import json, sys
tag, sha = sys.argv[1:]
data = json.load(sys.stdin)
runs = data.get("workflow_runs", data) if isinstance(data, dict) else data
for run in runs:
if run.get("head_sha") == sha and (run.get("head_branch") == tag or run.get("name") == "release"):
print(run.get("id", ""), run.get("conclusion") or run.get("status") or "unknown")
break
' "v$NEW_VERSION" "$RELEASE_SHA")
read -r RUN_ID STATUS <<< "$RUN"
if [ -n "$STATUS" ]; then
echo " run ${RUN_ID:-unknown} for v$NEW_VERSION$STATUS"
case "$STATUS" in
success) ok "release pipeline finished"; break ;;
failure) fail "pipeline failed — see $GITEA/$OWNER_REPO/actions"; exit 1 ;;
cancelled|canceled) fail "pipeline was cancelled"; exit 1 ;;
esac
else
echo " no run for v$NEW_VERSION picked up yet (waiting on a runner?)"
fi
done
echo
echo "Done. Watch it live: $GITEA/$OWNER_REPO/actions"
echo "Release lands at: $GITEA/$OWNER_REPO/releases"