rs_maps/rust_push.sh

276 lines
10 KiB
Bash
Raw Permalink Normal View History

2026-08-03 09:42:13 +01:00
#!/usr/bin/env bash
# rust_push.sh
#
# Usage:
# ./rust_push.sh "commit message" [patch|minor|major]
#
# Order of operations matters here: everything that can fail locally (validation,
# version bump, build) happens BEFORE anything is committed, tagged or pushed.
# A failed build therefore leaves the repository exactly as it found it.
set -euo pipefail
MSG="${1:-}"
BUMP="${2:-}"
export CROSS_CONTAINER_ENGINE=podman
TARGET="x86_64-unknown-linux-musl"
FORGE_HOST="radiantfig.fyi"
# ─────────────────────────────── pre-flight ────────────────────────────────
# All of it, before the first mutating command.
if [ -z "$MSG" ] || [ -z "$BUMP" ]; then
echo "Usage: ./rust_push.sh \"Commit message\" [patch|minor|major]"
exit 1
fi
case "$BUMP" in
major|minor|patch) ;;
*) echo "❌ Invalid bump: $BUMP (use patch|minor|major)"; exit 1 ;;
esac
[ -f "Cargo.toml" ] || { echo "❌ Cargo.toml not found."; exit 1; }
if [ -z "${FORGEJO_TOKEN:-}" ]; then
echo "❌ FORGEJO_TOKEN is not set."
echo " Generate one at ${FORGE_HOST}/forgejo -> Settings -> Applications"
exit 1
fi
command -v jq >/dev/null 2>&1 || { echo "❌ 'jq' is required."; exit 1; }
command -v curl >/dev/null 2>&1 || { echo "❌ 'curl' is required."; exit 1; }
CROSS_PATH="$HOME/.cargo/bin/cross"
[ -x "$CROSS_PATH" ] || { echo "❌ cross not found at $CROSS_PATH (cargo install cross)"; exit 1; }
# `git add .` is indiscriminate, so verify the ignore rules exist BEFORE relying
# on them. On a fresh repo nothing is tracked yet, which means a check for
# "already tracked" would pass while the very next `git add .` commits the lot.
[ -f ".gitignore" ] || { echo "❌ No .gitignore. Create one before the first release (target/ and .env must be excluded)."; exit 1; }
for secret in .env .env.local; do
[ -e "$secret" ] || continue
# Would `git add .` pick this up?
if ! git check-ignore -q "$secret"; then
echo "$secret is NOT ignored by git."
echo " It holds REG_TOKEN, the database password and SMTP credentials."
echo " Add it to .gitignore before releasing."
exit 1
fi
# Already committed in a previous run?
if git ls-files --error-unmatch "$secret" >/dev/null 2>&1; then
echo "$secret is tracked by git despite being ignored."
echo " git rm --cached $secret"
exit 1
fi
done
if ! git check-ignore -q target 2>/dev/null; then
echo "❌ target/ is not ignored — a release build is several GB."
exit 1
fi
# Compile-time embeds: sqlx::migrate! and rust-embed both read these at build
# time, and both fail with an unhelpful message if the directory is absent.
[ -d "migrations" ] || { echo "❌ migrations/ is missing (sqlx::migrate! needs it)"; exit 1; }
[ -d "frontend" ] || { echo "❌ frontend/ is missing (rust-embed needs it)"; exit 1; }
BINARY_NAME=$(grep -m 1 '^name[[:space:]]*=' Cargo.toml | sed -E 's/name[[:space:]]*=[[:space:]]*"(.*)"/\1/')
[ -n "$BINARY_NAME" ] || { echo "❌ Could not parse 'name' from Cargo.toml."; exit 1; }
# A bare `git remote get-url` on a fresh repo dies with a raw git error, so
# check first and say what to do about it.
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "❌ Not a git repository. Run: git init"
exit 1
fi
if ! git remote get-url origin >/dev/null 2>&1; then
echo "❌ No 'origin' remote configured."
echo " Create the repo on ${FORGE_HOST}/forgejo, then:"
echo " git remote add origin https://${FORGE_HOST}/forgejo/<owner>/<repo>.git"
exit 1
fi
# Handles both https://…/forgejo/owner/repo.git and git@host:owner/repo.git
ORIGIN_URL=$(git remote get-url origin)
REPO=$(echo "$ORIGIN_URL" | sed -E "
s|\.git$||;
s|^[^/]*//[^@]*@|https://|;
s|^https?://${FORGE_HOST}/forgejo/||;
s|^[^@]*@${FORGE_HOST}:||;
")
if echo "$REPO" | grep -q '[:/]\{1\}.*[:/]'; then
echo "❌ Could not parse owner/repo from remote: $ORIGIN_URL"
echo " Got: $REPO"
exit 1
fi
API_URL="https://${FORGE_HOST}/forgejo/api/v1/repos/${REPO}"
# Releasing a dirty tree means `git add .` sweeps unrelated work into the tag.
# Skipped on a repo with no commits yet, where everything is legitimately new.
if git rev-parse HEAD >/dev/null 2>&1 && [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "⚠️ Working tree has uncommitted changes; they will be included."
read -r -p " Continue? [y/N] " reply
[ "$reply" = "y" ] || exit 1
fi
# ──────────────────────────── version bumping ──────────────────────────────
# Harmless on a fresh repo with an empty remote; don't let it abort the run.
git fetch --tags --force --quiet 2>/dev/null || true
LATEST=$(git tag --list 'v*' | sort -V | tail -n 1)
LATEST=${LATEST#v}
[ -n "$LATEST" ] || LATEST="0.0.0"
MAJOR=$(echo "$LATEST" | cut -d. -f1)
MINOR=$(echo "$LATEST" | cut -d. -f2)
PATCH=$(echo "$LATEST" | cut -d. -f3)
case "$BUMP" in
major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR+1)); PATCH=0 ;;
patch) PATCH=$((PATCH+1)) ;;
esac
VERSION="$MAJOR.$MINOR.$PATCH"
TAG="v$VERSION"
if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then
echo "❌ Tag $TAG already exists on remote."
exit 1
fi
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "❌ Tag $TAG already exists locally (left over from a failed run?)."
echo " Remove it with: git tag -d $TAG"
exit 1
fi
echo "📦 Project: $BINARY_NAME"
echo "🔗 Repo: $REPO"
echo "📌 Previous: $LATEST"
echo "✨ New: $TAG"
echo
# Write the version into Cargo.toml so CARGO_PKG_VERSION matches the tag and the
# artifact name. Only the [package] version — the first `version =` in the file.
sed -i "0,/^version[[:space:]]*=.*/s//version = \"$VERSION\"/" Cargo.toml
# Keeps Cargo.lock in step so it isn't left dirty after the build.
cargo update --workspace --quiet 2>/dev/null || true
restore_manifest() {
echo "↩️ Restoring Cargo.toml"
git checkout -- Cargo.toml Cargo.lock 2>/dev/null || true
}
# ──────────────────────────────── build ────────────────────────────────────
# Before any git mutation: a failure here leaves the repo untouched.
echo "🚀 Compiling static binary ($TARGET) via cross..."
trap restore_manifest ERR
"$CROSS_PATH" build --release --target "$TARGET"
trap - ERR
BIN_PATH="target/$TARGET/release/$BINARY_NAME"
[ -f "$BIN_PATH" ] || { echo "❌ Binary not found at $BIN_PATH"; restore_manifest; exit 1; }
# [profile.release] already sets strip = true, so this is belt and braces.
BINARY_INFO=$(file "$BIN_PATH")
if echo "$BINARY_INFO" | grep -qE "statically linked|static-pie linked"; then
echo "✅ Binary is fully static"
else
echo "⚠️ Binary may not be static: $BINARY_INFO"
fi
ARTIFACT_NAME="${BINARY_NAME}-${VERSION}-linux-amd64-musl.tar.gz"
echo "📦 Packaging: $ARTIFACT_NAME"
tar -czf "$ARTIFACT_NAME" -C "target/$TARGET/release" "$BINARY_NAME"
# ────────────────────────── git commit / tag / push ────────────────────────
# Only now, with a working artifact in hand.
echo "📦 Committing and tagging..."
git add .
git commit -m "$MSG" || echo " (nothing to commit)"
git tag "$TAG"
echo "🌐 Pushing..."
git push -u origin HEAD
git push origin "$TAG"
# ────────────────────────────── forgejo release ────────────────────────────
echo "🌐 Creating release..."
curl -fsS -X PATCH "$API_URL" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
-d '{"has_releases": true}' > /dev/null
RELEASE_ID=$(curl -fsS "${API_URL}/releases" \
-H "Authorization: token $FORGEJO_TOKEN" |
jq -r ".[] | select(.tag_name==\"${TAG}\") | .id")
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "null" ]; then
echo "⚠️ Release $TAG already exists (ID: $RELEASE_ID)"
else
HTTP_CODE=""
for attempt in 1 2 3; do
RESPONSE=$(curl -sS -w "\n%{http_code}" -X POST "${API_URL}/releases" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"Release ${VERSION}\"}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
[ "$HTTP_CODE" = "201" ] && { RELEASE_ID=$(echo "$BODY" | jq -r .id); break; }
[ "$attempt" -lt 3 ] && { echo "⚠️ Attempt $attempt failed (HTTP $HTTP_CODE), retrying..."; sleep 2; }
done
if [ "$HTTP_CODE" != "201" ]; then
echo "❌ Could not create release. Response: ${BODY:-<empty>}"
echo " Artifact kept at ./$ARTIFACT_NAME for manual upload."
exit 1
fi
fi
# An empty ID here would silently produce /releases//assets and a confusing 404.
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "❌ No usable release ID. Artifact kept at ./$ARTIFACT_NAME"
exit 1
fi
echo "⬆️ Uploading artifact..."
UPLOAD_URL="${API_URL}/releases/${RELEASE_ID}/assets?name=${ARTIFACT_NAME}"
UPLOAD_HTTP=""
for attempt in 1 2 3; do
UPLOAD_RESPONSE=$(curl -sS -w "\n%{http_code}" -X POST "$UPLOAD_URL" \
-H "Authorization: token $FORGEJO_TOKEN" \
-F "attachment=@${ARTIFACT_NAME}")
UPLOAD_HTTP=$(echo "$UPLOAD_RESPONSE" | tail -n1)
[ "$UPLOAD_HTTP" = "201" ] && break
[ "$attempt" -lt 3 ] && { echo "⚠️ Upload attempt $attempt failed (HTTP $UPLOAD_HTTP), retrying..."; sleep 2; }
done
if [ "$UPLOAD_HTTP" != "201" ]; then
echo "❌ Upload failed (HTTP $UPLOAD_HTTP). Artifact kept at ./$ARTIFACT_NAME"
exit 1
fi
rm -f "$ARTIFACT_NAME"
echo
echo "✅ Release $TAG complete"
echo " https://${FORGE_HOST}/forgejo/${REPO}/releases/tag/${TAG}"