275 lines
10 KiB
Bash
Executable file
275 lines
10 KiB
Bash
Executable file
#!/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}"
|