set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
usage() {
cat >&2 <<'EOF'
Usage: s/version++ [patch|minor|major] [--pr|--direct] [--auto-merge]
bump defaults to patch; mode auto-detects from main branch protection.
--pr / --direct force the landing mode; --auto-merge queues the PR merge on green CI.
EOF
}
bump="patch"; mode=""; auto_merge=false
for arg in "$@"; do
case "$arg" in
patch|minor|major) bump="$arg" ;;
--pr) mode="pr" ;;
--direct) mode="direct" ;;
--auto-merge) auto_merge=true ;;
-h|--help) usage; exit 0 ;;
*) usage; echo "Unknown argument: $arg" >&2; exit 2 ;;
esac
done
branch="$(git rev-parse --abbrev-ref HEAD)"
if [[ "$branch" != "main" ]]; then
echo "On '$branch', not 'main'. Releases are cut from main." >&2
exit 1
fi
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Working tree not clean. Commit your feature work first, then release." >&2
exit 1
fi
need() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$1 is required by s/version++ but isn't installed." >&2
echo " $2" >&2
exit 1
fi
}
need cargo-set-version "cargo install cargo-edit --locked"
need git-cliff "cargo install git-cliff --locked"
git pull --ff-only
if [ -z "$mode" ]; then
if [ -n "${CALC_RELEASE_VIA_PR:-}" ]; then
mode="pr"
elif command -v gh >/dev/null 2>&1 \
&& gh repo view --json nameWithOwner -q .nameWithOwner >/dev/null 2>&1; then
repo="$(gh repo view --json nameWithOwner -q .nameWithOwner)"
if gh api "repos/$repo/branches/main/protection" >/dev/null 2>&1; then
mode="pr"
else
mode="direct"
fi
else
mode="direct"
echo "Note: 'gh' not available; assuming direct push to main (no branch-protection check)." >&2
fi
fi
echo "Release mode: $mode"
echo "Pre-release checks (mirror .github/workflows/ci.yml): fmt + clippy + tests..."
s/test
current="$(awk -F\" '/^version[[:space:]]*=/ {print $2; exit}' Cargo.toml)"
if [[ -z "$current" ]]; then
echo "Could not read workspace version from Cargo.toml." >&2
exit 1
fi
echo "Current version: $current"
cargo set-version --bump "$bump"
next="$(awk -F\" '/^version[[:space:]]*=/ {print $2; exit}' Cargo.toml)"
if [[ -z "$next" || "$next" == "$current" ]]; then
echo "Version did not change." >&2
exit 1
fi
echo "Bumped $bump: $current -> $next"
tag="v$next"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "Tag $tag already exists." >&2
exit 1
fi
cargo set-version --manifest-path python/Cargo.toml "$next"
cargo set-version --manifest-path gui/src-tauri/Cargo.toml "$next"
(
cd gui
npm version "$next" --no-git-tag-version >/dev/null
)
node -e '
const fs = require("fs");
const path = "gui/src-tauri/tauri.conf.json";
const config = JSON.parse(fs.readFileSync(path, "utf8"));
config.version = process.argv[1];
fs.writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
' "$next"
cargo generate-lockfile
cargo generate-lockfile --manifest-path python/Cargo.toml
cargo generate-lockfile --manifest-path gui/src-tauri/Cargo.toml
if [ "$mode" = "pr" ]; then
relbranch="release/$tag"
if git rev-parse -q --verify "refs/heads/$relbranch" >/dev/null \
|| git rev-parse -q --verify "refs/remotes/origin/$relbranch" >/dev/null; then
echo "Branch $relbranch already exists locally or on origin; delete it first." >&2
exit 1
fi
git checkout -b "$relbranch"
fi
git-cliff --output CHANGELOG.md --tag "$tag"
git add Cargo.toml Cargo.lock CHANGELOG.md \
python/Cargo.toml python/Cargo.lock python/pyproject.toml \
gui/package.json gui/package-lock.json \
gui/src-tauri/Cargo.toml gui/src-tauri/Cargo.lock \
gui/src-tauri/tauri.conf.json
git commit -m "chore(release): $tag"
if [ "$mode" = "direct" ]; then
git push origin main
echo
echo "Pushed $tag bump to main. auto-tag.yml will create the tag and invoke"
echo "release.yml (cargo-dist binaries + Homebrew tap), publish-crates.yml"
echo "(clincalc to crates.io), and publish-python.yml (clincalc to PyPI)."
else
git push -u origin "$relbranch"
pr_url=""
if command -v gh >/dev/null 2>&1; then
pr_url="$(gh pr create --base main --head "$relbranch" \
--title "chore(release): $tag" \
--body "Release bump generated by \`s/version++\`. Merging triggers auto-tag.yml -> release.yml + publish-crates.yml + publish-python.yml." \
2>/dev/null || true)"
fi
if [ -n "$pr_url" ] && [ "$auto_merge" = true ]; then
gh pr merge "$pr_url" --merge --auto --delete-branch 2>/dev/null || true
echo
echo "PR opened and queued to auto-merge on green CI: $pr_url"
echo "On merge, auto-tag.yml creates $tag and invokes the release workflows."
elif [ -n "$pr_url" ]; then
echo
echo "PR opened: $pr_url"
echo "Merge it (CI runs on the PR); auto-tag.yml then creates $tag and releases."
else
repo="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || echo 'OWNER/REPO')"
echo
echo "Pushed $relbranch. Open and merge a PR against main:"
echo " https://github.com/$repo/compare/main...$relbranch"
echo "On merge, auto-tag.yml creates $tag and invokes the release workflows."
fi
fi