#!/usr/bin/env bash# Derive the next SemVer release from Conventional Commits, then bump, tag, and push it.## This script is the SINGLE writer of the version. `packages/solver/solver/version.py` (tracked)# is the source of truth: pyproject.toml stamps it into the wheel at build time# and `config.version` / the `version` shell command report it at runtime. A# release rewrites that file, commits it, tags the commit `vX.Y.Z`, and pushes the# commit + tag to origin — so the file, the tag, and the wheel metadata always# agree, and origin never lags a local-only release (which stranded collaborator# clones behind a redeployed venv). There is no hand-edited number, no build-time# git. Publishing the tag is the step most easily forgotten by hand, so it is not# left to the caller.## Bump rule — scan `<last-tag>..HEAD` subjects/bodies for Conventional-Commit# markers and take the highest present:# * `feat!:` / `fix!:` / a `BREAKING CHANGE:` body → MAJOR# * any `feat:` / `feat(scope):` → MINOR# * anything else (fix, perf, refactor, …) → PATCH## Releasing is a deliberate step, run by a maintainer from a clean checkout — not# automatic on push. Pass --dry-run to preview the release and exit, or --no-push# to bump/commit/tag locally but stop before publishing to origin.## Usage:# scripts/version/release.sh [--dry-run] [--no-push]set-uopipefaildeclaredry_run=0no_push=0forargin"$@";docase"${arg}"in--dry-run)dry_run=1;;--no-push)no_push=1;;*)echo"release: unknown argument '${arg}'">&2;exit1;;esacdone# The last release anchor, or v0.0.0 when none exists yet (the pre-first-tag case).last=$(gitdescribe--tags--abbrev=0--match'v*'2>/dev/null||echov0.0.0)IFS=.read-rmajorminorpatch<<<"${last#v}"if[[-z"${major:-}"||-z"${minor:-}"||-z"${patch:-}"]];thenecho"release: cannot parse last tag '$last' as vMAJOR.MINOR.PATCH">&2exit1fi# The commit log to classify: everything since the last tag (all of history when# there is no tag yet, so the very first release still classifies correctly).if[["$last"=="v0.0.0"]]&&!gitrev-parse-q--verify"$last">/dev/null2>&1;thenrange="HEAD"elserange="${last}..HEAD"filog=$(gitlog--format='%s%n%b'"$range")if[[-z"$log"]];thenecho"release: no commits since $last — nothing to release">&2exit1fiifgrep-qE'^[a-z]+(\([^)]+\))?!:|BREAKING CHANGE'<<<"$log";thenmajor=$((major+1));minor=0;patch=0elifgrep-qE'^feat(\([^)]+\))?:'<<<"$log";thenminor=$((minor+1));patch=0elsepatch=$((patch+1))finew="v${major}.${minor}.${patch}"number="${new#v}"root="$(gitrev-parse--show-toplevel)"# Lockstep (docs/package-guide.md § 5.2): one number in every package's version.py, and in the# pins between them — euler-site and euler-webshell require exactly this euler-solver.version_files=("${root}/packages/solver/solver/version.py""${root}/packages/site/euler_site/version.py""${root}/packages/webshell/euler_webshell/version.py")pin_files=("${root}/packages/site/pyproject.toml""${root}/packages/webshell/pyproject.toml")if((dry_run));thentail_action="commits, tags, then pushes to origin"((no_push))&&tail_action="commits, then tags (no push)"echo"[dry-run] $last -> $new (writes $number to the three packages' version.py and pins, ${tail_action})"exit0fi# Cleanliness via `git status --porcelain`, NOT `git diff`: the transparent# encryption filter (solutions/private/**) re-encrypts to fresh ciphertext on# every read, so `git diff --quiet` reports phantom churn on an otherwise clean# tree. `git status` refreshes the index and ignores that noise.if[[-n"$(gitstatus--porcelain)"]];thenecho"release: working tree is dirty — commit or stash before releasing $new">&2exit1fi# Rewrite ONLY the numbers (leaving the docstrings and the `version = __version__` aliases# intact), commit them, then tag THAT commit — so the tag, every version.py, the pins, and the# wheels built from them all name $number.forversion_filein"${version_files[@]}";doif!grep-qE"^__version__ = '[^']*'""$version_file";thenecho"release: cannot find the __version__ line in $version_file">&2exit1fised-i-E"s/^__version__ = '[^']*'/__version__ = '${number}'/""$version_file"doneforpin_filein"${pin_files[@]}";dosed-i-E"s/\"(euler-(solver|site))==[^\"]*\"/\"\1==${number}\"/""$pin_file"donegitadd"${version_files[@]}""${pin_files[@]}"gitcommit-q-m"chore(release): $new"gittag-a"$new"-m"release $new"echo"released $new — every package's version.py and pins bumped, committed, and tagged"if((no_push));thenecho"not pushed (--no-push); publish with: git push origin HEAD $new"exit0fi# Push the release commit AND the tag together. Without the tag on origin, every# collaborator clone's `git describe` anchors to the previous release and a# redeployed venv runs ahead of what any clone can reach. A push failure is not# rolled back — the commit and tag are valid locally; report and let the caller# retry, since the tree is already the release state.echo"pushing $new (commit + tag) to origin..."if!gitpushoriginHEAD"$new";thenecho"release: push FAILED — $new is committed and tagged locally but NOT on origin.">&2echo" retry with: git push origin HEAD $new">&2exit1fiecho"pushed $new to origin — remember to 'make redeploy-web' to ship it"