#!/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//.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:-}" 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}"