diff --git a/RELEASE.md b/RELEASE.md index 88d763a32..119e6ac38 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,19 +1,72 @@ # Release Checklist -The following are the steps to follow to make a new PySCIPOpt release. They should mostly be done in order. -- [ ] Check if [scipoptsuite-deploy](https://github.com/scipopt/scipoptsuite-deploy) needs a new release, if a new SCIP version is released for example, or new dependencies (change symmetry dependency, add support for papilo/ parallelization.. etc). And Update release links in `pyproject.toml` -- [ ] Check if the table in the [documentation](https://pyscipopt.readthedocs.io/en/latest/build.html#building-from-source) needs to be updated. -- [ ] Update version number according to semantic versioning [rules](https://semver.org/) in `src/pyscipopt/_version.py` and `setup.py` -- [ ] Update `CHANGELOG.md`; Change the `Unreleased` to the new version number and add an empty unreleased section. -- [ ] Create a release candidate on test-pypi by running the workflow “Build wheels” in Actions->build wheels, with these parameters `upload:true, test-pypi:true`  -- [ ] If the pipeline passes, test the released pip package on test-pypi by running and checking that it works + +## Upgrading SCIP + +Run `./upgrade_scip.sh` from the `master` branch (use `--dry-run` first to preview without side effects). The script will: +1. Prompt for SCIP, SoPlex, GCG, and IPOPT versions +2. Build new binaries via [scipoptsuite-deploy](https://github.com/scipopt/scipoptsuite-deploy) (skipped if a matching release already exists) +3. Create a branch, update `pyproject.toml`, and open a PR + +On the PR: +- [ ] Fix any API incompatibilities +- [ ] Get CI green +- [ ] Update the [compatibility table](https://pyscipopt.readthedocs.io/en/latest/build.html#building-from-source) if needed +- [ ] Merge into `master` + +## Releasing PySCIPOpt + +Releases run in two phases from `master`, driven by `./release.sh`. The tag and master push only happen in phase 2, so an aborted release leaves no semantic public trace — just a deletable `release-candidate-vX.Y.Z` branch. + +Use `--dry-run` with any command to preview without side effects. + +### Phase 1 — start + ```bash -pip install -i https://test.pypi.org/simple/ PySCIPOpt +./release.sh ``` -- [ ] If it works, release on pypi.org with running the same workflow but with `test-pypi:false`. -- [ ] Then create a tag with the new version (from the master branch) + +Prompts for the version bump (patch/minor/major), updates `_version.py`, `setup.py`, and `CHANGELOG.md`, commits **locally**, pushes the commit to `release-candidate-vX.Y.Z` on origin, and triggers the build-wheels workflow on that branch (uploads to test-pypi). **Master is not pushed, no tag is created.** The script exits as soon as the workflow is dispatched. + +To skip the bump prompt (e.g., when test-pypi has already burnt the default next version and you need to jump ahead): + ```bash -git tag vX.X.X -git push origin vX.X.X +./release.sh --version=X.Y.Z ``` -- [ ] Then make a github [release](https://github.com/scipopt/PySCIPOpt/releases/new) from this new tag. -- [ ] Update the documentation: from readthedocs.io -> Builds -> Build version (latest and stable) + +### Manual verification + +Once the release-candidate workflow finishes, install from test-pypi and smoke-test: + +```bash +pip install -i https://test.pypi.org/simple/ PySCIPOpt==X.Y.Z +``` + +### Phase 2 — finalize or roll back + +If the smoke test **passes**: + +```bash +./release.sh --finalize +``` + +Checks the release-candidate workflow succeeded, then tags `vX.Y.Z`, pushes master, and deletes the release-candidate branch. + +If the smoke test **fails** (or you change your mind): + +```bash +./release.sh --rollback +``` + +Deletes the release-candidate branch and resets the local release commit. test-pypi has already burnt the uploaded version string, so the next attempt must use `--version=` to pick a different one. + +### After finalize + +- [ ] Release to production pypi: + ```bash + gh workflow run build_wheels.yml --repo scipopt/PySCIPOpt --ref vX.Y.Z -f upload_to_pypi=true -f test_pypi=false + ``` +- [ ] Create a GitHub release: + ```bash + gh release create vX.Y.Z --repo scipopt/PySCIPOpt --title vX.Y.Z --generate-notes + ``` +- [ ] Update readthedocs: Builds -> Build version (latest and stable) diff --git a/release.sh b/release.sh new file mode 100755 index 000000000..44c8ce9eb --- /dev/null +++ b/release.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION_FILE="src/pyscipopt/_version.py" +SETUP_FILE="setup.py" +CHANGELOG="CHANGELOG.md" +REPO="scipopt/PySCIPOpt" + +DRY_RUN=false +ACTION=start +NEW_VERSION_OVERRIDE="" +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --finalize) ACTION=finalize ;; + --rollback) ACTION=rollback ;; + --version=*) NEW_VERSION_OVERRIDE="${arg#--version=}" ;; + -h|--help) + cat </dev/null; then + echo "Error: gh CLI is not installed. Install it from https://cli.github.com" + exit 1 +fi + +if ! gh auth status &>/dev/null; then + echo "Error: gh CLI is not authenticated. Run 'gh auth login' first." + exit 1 +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Error: working directory is not clean. Commit, stash, or remove changes first." + exit 1 +fi + +CURRENT_BRANCH=$(git branch --show-current) +if [[ "$CURRENT_BRANCH" != "master" ]]; then + if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: on '${CURRENT_BRANCH}' — a real run would require master." + else + echo "Error: must be on 'master' branch (currently on '${CURRENT_BRANCH}')." + exit 1 + fi +fi + +PUSH_REMOTE=$(git config --get "branch.${CURRENT_BRANCH}.remote" 2>/dev/null || echo origin) + +git pull --ff-only + +# --- Helper functions --- + +validate_version() { + if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: '$1' is not a valid version (expected X.Y.Z)" + exit 1 + fi +} + +# Promote a successful pending release: tag, push master, clean up release-candidate branch. +finalize_release() { + local version="$1" + local candidate_branch="release-candidate-v${version}" + + echo "Pending release: v${version}" + echo "Checking workflow status on ${candidate_branch}..." + + local run_data run_count status conclusion url run_id + run_data=$(gh run list --workflow=build_wheels.yml --repo "$REPO" \ + --branch "$candidate_branch" --limit 1 \ + --json databaseId,status,conclusion,url 2>/dev/null || echo "[]") + run_count=$(echo "$run_data" | jq 'length') + + if [[ "$run_count" == "0" ]]; then + echo "Error: no workflow run found for branch '${candidate_branch}'." + echo "Either wait a moment and try again, or clean up manually:" + echo " git push ${PUSH_REMOTE} --delete ${candidate_branch}" + echo " git reset --hard HEAD~1" + exit 1 + fi + + status=$(echo "$run_data" | jq -r '.[0].status') + conclusion=$(echo "$run_data" | jq -r '.[0].conclusion') + url=$(echo "$run_data" | jq -r '.[0].url') + run_id=$(echo "$run_data" | jq -r '.[0].databaseId') + + if [[ "$status" != "completed" ]]; then + echo "Workflow still running (status: ${status})." + echo "URL: ${url}" + echo "Re-run this script once it finishes." + exit 0 + fi + + if [[ "$conclusion" != "success" ]]; then + echo "Workflow failed (conclusion: ${conclusion})." + echo "URL: ${url}" + echo "" + if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: would prompt to roll back (delete ${candidate_branch} + reset local commit)." + exit 0 + fi + read -rp "Roll back (delete ${candidate_branch} and reset local release commit)? [Y/n] " confirm + if [[ ! "${confirm:-Y}" =~ ^[Nn] ]]; then + git push "$PUSH_REMOTE" --delete "$candidate_branch" || echo "(note: remote release-candidate branch delete failed; clean up manually)" + git reset --hard HEAD~1 + echo "Rolled back. Repo is at pre-release state." + fi + exit 1 + fi + + # Success: promote. + if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: workflow succeeded (run ${run_id}). Would have run:" + echo " git tag v${version}" + echo " git push ${PUSH_REMOTE} ${CURRENT_BRANCH}" + echo " git push ${PUSH_REMOTE} v${version}" + echo " git push ${PUSH_REMOTE} --delete ${candidate_branch}" + exit 0 + fi + + echo "Workflow succeeded. Promoting release v${version}..." + git tag "v${version}" + git push "$PUSH_REMOTE" "$CURRENT_BRANCH" + git push "$PUSH_REMOTE" "v${version}" + git push "$PUSH_REMOTE" --delete "$candidate_branch" + + echo "" + echo "Done! v${version} is now tagged on ${PUSH_REMOTE}." + echo "" + echo "Remaining manual steps:" + echo " 1. Verify test-pypi:" + echo " pip install -i https://test.pypi.org/simple/ PySCIPOpt==${version}" + echo " 2. Release to production pypi:" + echo " gh workflow run build_wheels.yml --repo ${REPO} --ref v${version} -f upload_to_pypi=true -f test_pypi=false" + echo " 3. Create a GitHub release from tag v${version}:" + echo " gh release create v${version} --repo ${REPO} --title v${version} --generate-notes" + echo " 4. Update readthedocs: Builds -> Build version (latest and stable)" +} + +# Abandon a pending release: delete release-candidate branch, reset local release commit. +rollback_release() { + local version="$1" + local candidate_branch="release-candidate-v${version}" + + echo "Rolling back pending release v${version}..." + if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: would have run:" + echo " git push ${PUSH_REMOTE} --delete ${candidate_branch}" + echo " git reset --hard HEAD~1" + exit 0 + fi + + if git ls-remote --heads --exit-code "$PUSH_REMOTE" "$candidate_branch" &>/dev/null; then + git push "$PUSH_REMOTE" --delete "$candidate_branch" + else + echo "(note: ${candidate_branch} already absent from ${PUSH_REMOTE})" + fi + git reset --hard HEAD~1 + echo "Rolled back. Repo is at pre-release state." +} + +# Validate that HEAD is a local release commit; return the version via stdout. +require_pending_release() { + local head_msg + head_msg=$(git log -1 --format=%s) + if [[ ! "$head_msg" =~ ^release\ v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "Error: expected HEAD to be a 'release vX.Y.Z' commit, got: '${head_msg}'" >&2 + echo "Run without --finalize/--rollback to start a new release." >&2 + exit 1 + fi + echo "${BASH_REMATCH[1]}" +} + +# --- Dispatch finalize / rollback / start --- + +if [[ "$ACTION" == "finalize" ]]; then + PENDING_VERSION=$(require_pending_release) + finalize_release "$PENDING_VERSION" + exit 0 +fi + +if [[ "$ACTION" == "rollback" ]]; then + PENDING_VERSION=$(require_pending_release) + rollback_release "$PENDING_VERSION" + exit 0 +fi + +# Start path: if HEAD is already a release commit, the user probably forgot a flag. +HEAD_MSG=$(git log -1 --format=%s) +if [[ "$HEAD_MSG" =~ ^release\ v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "Error: HEAD is already 'release v${BASH_REMATCH[1]}' — a pending release exists." + echo "Use:" + echo " ./release.sh --finalize # promote (requires the release-candidate workflow to have succeeded)" + echo " ./release.sh --rollback # abandon (delete release-candidate branch, reset local commit)" + exit 1 +fi + +# --- Read current version --- + +CURRENT_VERSION=$(sed -n "s/^__version__.*'\(.*\)'/\1/p" "$VERSION_FILE") +validate_version "$CURRENT_VERSION" +MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) +MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) +PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) + +echo "Current version: ${CURRENT_VERSION}" + +# --- Determine new version (prompt, or use --version=X.Y.Z override) --- + +if [[ -n "$NEW_VERSION_OVERRIDE" ]]; then + validate_version "$NEW_VERSION_OVERRIDE" + NEW_VERSION="$NEW_VERSION_OVERRIDE" + echo "Using version (from --version): ${NEW_VERSION}" +else + echo "" + echo "Release type:" + echo " 1) patch -> $((MAJOR)).$((MINOR)).$((PATCH + 1))" + echo " 2) minor -> $((MAJOR)).$((MINOR + 1)).0" + echo " 3) major -> $((MAJOR + 1)).0.0" + echo "" + read -rp "Select [1/2/3]: " bump_type + + case "$bump_type" in + 1|patch) NEW_VERSION="$((MAJOR)).$((MINOR)).$((PATCH + 1))" ;; + 2|minor) NEW_VERSION="$((MAJOR)).$((MINOR + 1)).0" ;; + 3|major) NEW_VERSION="$((MAJOR + 1)).0.0" ;; + *) echo "Error: invalid selection '${bump_type}'"; exit 1 ;; + esac +fi + +# --- Check tag doesn't already exist --- + +if git rev-parse "v${NEW_VERSION}" &>/dev/null; then + echo "Error: tag 'v${NEW_VERSION}' already exists locally." + exit 1 +fi + +if git ls-remote --tags --exit-code "$PUSH_REMOTE" "refs/tags/v${NEW_VERSION}" &>/dev/null; then + echo "Error: tag 'v${NEW_VERSION}' already exists on ${PUSH_REMOTE}." + exit 1 +fi + +# --- Show summary and confirm --- + +echo "" +echo "Unreleased changelog entries:" +echo "-----------------------------" +sed -n '/^## Unreleased$/,/^## [0-9]/{/^## [0-9]/!p;}' "$CHANGELOG" | head -30 +echo "-----------------------------" + +UNRELEASED_BULLETS=$(sed -n '/^## Unreleased$/,/^## [0-9]/{/^## [0-9]/!p;}' "$CHANGELOG" | grep -c '^- ' || true) +if [[ "$UNRELEASED_BULLETS" == "0" ]]; then + echo "" + echo "Warning: the Unreleased section has no bullet entries." + read -rp "Release anyway? [y/N] " empty_confirm + [[ "${empty_confirm:-N}" =~ ^[Yy] ]] || exit 0 +fi + +TODAY=$(date +%Y.%m.%d) +CANDIDATE_BRANCH="release-candidate-v${NEW_VERSION}" +echo "" +if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: This script would:" +else + echo "This script will (phase 1 of 2):" +fi +echo " 1. Update version ${CURRENT_VERSION} -> ${NEW_VERSION} in _version.py and setup.py" +echo " 2. Update CHANGELOG.md (${NEW_VERSION} - ${TODAY})" +echo " 3. Commit locally, push commit to branch '${CANDIDATE_BRANCH}' on ${PUSH_REMOTE}" +echo " (master is NOT pushed; no tag is created yet)" +echo " 4. Trigger the build wheels workflow on that branch (test-pypi)" +echo "" +echo "Once the workflow finishes, re-run this script:" +echo " - success -> tag v${NEW_VERSION}, push master, delete release-candidate branch" +echo " - failure -> prompt to roll back (delete release-candidate branch + reset local commit)" +echo "" +if [[ "$DRY_RUN" == false ]]; then + read -rp "Proceed? [Y/n] " confirm + [[ "${confirm:-Y}" =~ ^[Nn] ]] && exit 0 +fi + +# ============================================================ +# From here on, everything runs without further prompts. +# If a step fails after commit, re-run this script: it detects the +# partial release commit at HEAD and prints recovery instructions. +# ============================================================ + +# --- Update version files --- + +sed -i.bak "s/__version__.*=.*'.*'/__version__: str = '${NEW_VERSION}'/" "$VERSION_FILE" +if cmp -s "$VERSION_FILE" "${VERSION_FILE}.bak"; then + echo "Error: failed to update version in $VERSION_FILE (pattern not found)" + mv "${VERSION_FILE}.bak" "$VERSION_FILE" + exit 1 +fi +rm -f "${VERSION_FILE}.bak" + +sed -i.bak "s/version=\"${CURRENT_VERSION}\"/version=\"${NEW_VERSION}\"/" "$SETUP_FILE" +if cmp -s "$SETUP_FILE" "${SETUP_FILE}.bak"; then + echo "Error: failed to update version in $SETUP_FILE (pattern not found)" + mv "${SETUP_FILE}.bak" "$SETUP_FILE" + exit 1 +fi +rm -f "${SETUP_FILE}.bak" + +echo "Updated version: ${CURRENT_VERSION} -> ${NEW_VERSION}" + +# --- Update changelog --- + +sed -i.bak "s/^## Unreleased$/## ${NEW_VERSION} - ${TODAY}/" "$CHANGELOG" +if cmp -s "$CHANGELOG" "${CHANGELOG}.bak"; then + echo "Error: failed to update changelog ('## Unreleased' heading not found)" + mv "${CHANGELOG}.bak" "$CHANGELOG" + exit 1 +fi +rm -f "${CHANGELOG}.bak" + +sed -i.bak "/^# CHANGELOG$/a\\ +\\ +## Unreleased\\ +### Added\\ +### Fixed\\ +### Changed\\ +### Removed\\ +" "$CHANGELOG" +if cmp -s "$CHANGELOG" "${CHANGELOG}.bak"; then + echo "Error: failed to insert fresh Unreleased section ('# CHANGELOG' heading not found)" + mv "${CHANGELOG}.bak" "$CHANGELOG" + exit 1 +fi +rm -f "${CHANGELOG}.bak" + +echo "Updated CHANGELOG.md" + +if [[ "$DRY_RUN" == true ]]; then + echo "" + echo "DRY RUN: planned file changes:" + git --no-pager diff -- "$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG" + echo "" + echo "DRY RUN: reverting local edits (no commit, push, or workflow trigger)." + git checkout -- "$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG" + echo "" + echo "DRY RUN: would have run:" + echo " git commit -m 'release v${NEW_VERSION}'" + echo " git push ${PUSH_REMOTE} HEAD:refs/heads/${CANDIDATE_BRANCH}" + echo " gh workflow run build_wheels.yml --repo ${REPO} --ref ${CANDIDATE_BRANCH} -f upload_to_pypi=true -f test_pypi=true" + exit 0 +fi + +# --- Commit locally and push release-candidate branch (no tag, no master push yet) --- + +git add "$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG" +git commit -m "release v${NEW_VERSION}" +git push "$PUSH_REMOTE" "HEAD:refs/heads/${CANDIDATE_BRANCH}" + +# --- Trigger test-pypi build against the release-candidate branch --- + +gh workflow run build_wheels.yml --repo "$REPO" --ref "$CANDIDATE_BRANCH" -f upload_to_pypi=true -f test_pypi=true + +echo "" +echo "Release candidate v${NEW_VERSION} started (phase 1 of 2):" +echo " - Local: release commit on ${CURRENT_BRANCH}, NOT pushed" +echo " - Remote: branch '${CANDIDATE_BRANCH}' has the release commit" +echo " - Workflow: https://github.com/${REPO}/actions?query=branch%3A${CANDIDATE_BRANCH}" +echo "" +echo "Re-run this script after the workflow finishes to finalize:" +echo " - success -> tag v${NEW_VERSION}, push ${CURRENT_BRANCH}, delete ${CANDIDATE_BRANCH}" +echo " - failure -> prompt to roll back" diff --git a/upgrade_scip.sh b/upgrade_scip.sh new file mode 100755 index 000000000..3341e31d6 --- /dev/null +++ b/upgrade_scip.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +set -euo pipefail + +PYPROJECT="pyproject.toml" +DEPLOY_REPO="scipopt/scipoptsuite-deploy" +REPO="scipopt/PySCIPOpt" + +DRY_RUN=false +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + -h|--help) echo "Usage: $0 [--dry-run]"; exit 0 ;; + *) echo "Error: unknown argument '$arg' (use --dry-run or --help)"; exit 1 ;; + esac +done + +# --- Pre-flight checks --- + +if ! command -v gh &>/dev/null; then + echo "Error: gh CLI is not installed. Install it from https://cli.github.com" + exit 1 +fi + +if ! gh auth status &>/dev/null; then + echo "Error: gh CLI is not authenticated. Run 'gh auth login' first." + exit 1 +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Error: working directory is not clean. Commit, stash, or remove changes first." + exit 1 +fi + +CURRENT_BRANCH=$(git branch --show-current) +if [[ "$CURRENT_BRANCH" != "master" ]]; then + if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: on '${CURRENT_BRANCH}' — a real run would require master." + else + echo "Error: must be on 'master' branch (currently on '${CURRENT_BRANCH}')." + exit 1 + fi +fi + +PUSH_REMOTE=$(git config --get "branch.${CURRENT_BRANCH}.remote" 2>/dev/null || echo origin) + +git pull --ff-only + +# --- Helper functions --- + +validate_version() { + if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: '$1' is not a valid version (expected X.Y.Z)" + exit 1 + fi +} + +validate_deploy_version() { + if [[ ! "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: '$1' is not a valid deploy version (expected vX.Y.Z)" + exit 1 + fi +} + +prompt_version() { + local label="$1" current="$2" + read -rp "${label} [${current}]: " value + value="${value:-$current}" + validate_version "$value" + echo "$value" +} + +# --- Collect all inputs --- + +CURRENT_DEPLOY_VERSION=$(grep -o 'scipoptsuite-deploy/releases/download/v[0-9.]*' "$PYPROJECT" | head -1 | sed 's|.*/||') + +if [[ -z "$CURRENT_DEPLOY_VERSION" ]]; then + echo "Error: could not find a scipoptsuite-deploy/releases/download/vX.Y.Z URL in ${PYPROJECT}." + exit 1 +fi +validate_deploy_version "$CURRENT_DEPLOY_VERSION" + +echo "Current scipoptsuite-deploy version: ${CURRENT_DEPLOY_VERSION}" +echo "" +echo "Enter component versions (press enter to keep current):" + +# Fetch current defaults from the deploy workflow +DEPLOY_WORKFLOW=$(gh api repos/${DEPLOY_REPO}/contents/.github/workflows/build_binaries.yml --jq '.content' | base64 --decode) +current_deploy_default() { + echo "$DEPLOY_WORKFLOW" | sed -n "/${1}:/,/default:/{s/.*default: \"\(.*\)\"/\1/p;}" | head -1 +} + +CUR_SCIP=$(current_deploy_default "scip_version") +CUR_SOPLEX=$(current_deploy_default "soplex_version") +CUR_GCG=$(current_deploy_default "gcg_version") +CUR_IPOPT=$(current_deploy_default "ipopt_version") + +SCIP_VERSION=$(prompt_version "SCIP" "$CUR_SCIP") +SOPLEX_VERSION=$(prompt_version "SoPlex" "$CUR_SOPLEX") +GCG_VERSION=$(prompt_version "GCG" "$CUR_GCG") +IPOPT_VERSION=$(prompt_version "IPOPT" "$CUR_IPOPT") + +# --- Check if a matching deploy release already exists --- + +RELEASE_NAME="SCIP ${SCIP_VERSION} SOPLEX ${SOPLEX_VERSION} GCG ${GCG_VERSION} IPOPT ${IPOPT_VERSION}" +EXISTING_TAG=$(gh release list --repo "$DEPLOY_REPO" --limit 20 --json tagName,name \ + --jq ".[] | select(.name == \"${RELEASE_NAME}\") | .tagName" | head -1) + +SKIP_DEPLOY=false +if [[ -n "$EXISTING_TAG" ]]; then + echo "Found existing release '${EXISTING_TAG}' matching these versions. Skipping build." + NEW_DEPLOY_VERSION="$EXISTING_TAG" + SKIP_DEPLOY=true +else + # Bump deploy version (increment minor) + DEPLOY_MAJOR=$(echo "$CURRENT_DEPLOY_VERSION" | sed 's/^v//' | cut -d. -f1) + DEPLOY_MINOR=$(echo "$CURRENT_DEPLOY_VERSION" | sed 's/^v//' | cut -d. -f2) + DEPLOY_PATCH=$(echo "$CURRENT_DEPLOY_VERSION" | sed 's/^v//' | cut -d. -f3) + SUGGESTED_DEPLOY="v$((DEPLOY_MAJOR)).$((DEPLOY_MINOR + 1)).$((DEPLOY_PATCH))" + + read -rp "New deploy release tag [${SUGGESTED_DEPLOY}]: " NEW_DEPLOY_VERSION + NEW_DEPLOY_VERSION="${NEW_DEPLOY_VERSION:-$SUGGESTED_DEPLOY}" + + validate_deploy_version "$NEW_DEPLOY_VERSION" + + if gh release view "$NEW_DEPLOY_VERSION" --repo "$DEPLOY_REPO" &>/dev/null; then + echo "Error: deploy tag ${NEW_DEPLOY_VERSION} already exists in ${DEPLOY_REPO} (with different versions)." + exit 1 + fi +fi + +if [[ "$CURRENT_DEPLOY_VERSION" == "$NEW_DEPLOY_VERSION" ]]; then + echo "Error: new deploy version (${NEW_DEPLOY_VERSION}) matches current; nothing to upgrade." + exit 1 +fi + +# --- Show summary and confirm --- + +BRANCH="upgrade-scip-${SCIP_VERSION}" + +echo "" +if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: This script would:" +else + echo "This script will:" +fi +if [[ "$SKIP_DEPLOY" == false ]]; then + echo " 1. Build new SCIP binaries (SCIP=${SCIP_VERSION} SoPlex=${SOPLEX_VERSION} GCG=${GCG_VERSION} IPOPT=${IPOPT_VERSION})" + echo " 2. Create scipoptsuite-deploy release ${NEW_DEPLOY_VERSION}" + echo " 3. Create branch '${BRANCH}', update pyproject.toml, and open a PR" +else + echo " 1. [skip] Build binaries — release ${NEW_DEPLOY_VERSION} already exists" + echo " 2. [skip] Create release — already exists" + echo " 3. Create branch '${BRANCH}', update pyproject.toml, and open a PR" +fi +echo "" + +if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: would have run:" + if [[ "$SKIP_DEPLOY" == false ]]; then + echo " gh workflow run build_binaries.yml --repo ${DEPLOY_REPO} \\" + echo " -f scip_version=${SCIP_VERSION} -f soplex_version=${SOPLEX_VERSION} \\" + echo " -f gcg_version=${GCG_VERSION} -f ipopt_version=${IPOPT_VERSION}" + echo " (wait for run, download artifacts)" + echo " gh release create ${NEW_DEPLOY_VERSION} --repo ${DEPLOY_REPO} ..." + fi + echo " git checkout -b ${BRANCH}" + echo " (sed) ${CURRENT_DEPLOY_VERSION} -> ${NEW_DEPLOY_VERSION} in ${PYPROJECT}" + echo " git commit -m 'Update scipoptsuite-deploy to ${NEW_DEPLOY_VERSION} (SCIP ${SCIP_VERSION})'" + echo " git push -u ${PUSH_REMOTE} ${BRANCH}" + echo " gh pr create --repo ${REPO} --title 'Upgrade to SCIP ${SCIP_VERSION}'" + exit 0 +fi + +read -rp "Proceed? [Y/n] " confirm +[[ "${confirm:-Y}" =~ ^[Nn] ]] && exit 0 + +# ============================================================ +# From here on, everything runs without further prompts. +# ============================================================ + +if [[ "$SKIP_DEPLOY" == false ]]; then + + ARTIFACT_DIR="" + cleanup() { [[ -n "$ARTIFACT_DIR" ]] && rm -rf "$ARTIFACT_DIR"; } + trap cleanup EXIT + + # --- Build SCIP binaries --- + + echo "" + echo "Triggering SCIP binary build..." + DISPATCH_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) + gh workflow run build_binaries.yml --repo "$DEPLOY_REPO" \ + -f scip_version="$SCIP_VERSION" \ + -f soplex_version="$SOPLEX_VERSION" \ + -f gcg_version="$GCG_VERSION" \ + -f ipopt_version="$IPOPT_VERSION" + + # Wait for the run to appear. Filter by actor and earliest createdAt so a + # concurrent workflow_dispatch (by us or someone else) can't hijack RUN_ID. + MY_LOGIN=$(gh api user --jq .login) + for i in {1..12}; do + sleep 5 + RUN_ID=$(gh run list --workflow=build_binaries.yml --repo "$DEPLOY_REPO" \ + --limit 20 --event workflow_dispatch \ + --json databaseId,createdAt,actor \ + --jq "[.[] | select(.createdAt >= \"${DISPATCH_TIME}\") | select(.actor.login == \"${MY_LOGIN}\")] | sort_by(.createdAt) | .[0].databaseId") + [[ -n "$RUN_ID" && "$RUN_ID" != "null" ]] && break + done + + if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then + echo "Error: could not find the triggered workflow run." + exit 1 + fi + + echo "Waiting for build to complete (run ${RUN_ID})..." + echo " https://github.com/${DEPLOY_REPO}/actions/runs/${RUN_ID}" + gh run watch "$RUN_ID" --repo "$DEPLOY_REPO" --exit-status + + # --- Create deploy release --- + + ARTIFACT_DIR=$(mktemp -d) + echo "Downloading artifacts..." + gh run download "$RUN_ID" --repo "$DEPLOY_REPO" --dir "$ARTIFACT_DIR" + + shopt -s nullglob + for subdir in linux linux-arm macos-arm macos-intel windows; do + zips=("$ARTIFACT_DIR/$subdir"/*.zip) + if [[ ${#zips[@]} -eq 0 ]]; then + echo "Error: no .zip files found in $ARTIFACT_DIR/$subdir/ — artifact layout may have changed." + exit 1 + fi + done + shopt -u nullglob + + RELEASE_NAME="SCIP ${SCIP_VERSION} SOPLEX ${SOPLEX_VERSION} GCG ${GCG_VERSION} IPOPT ${IPOPT_VERSION}" + echo "Creating release ${NEW_DEPLOY_VERSION}..." + gh release create "$NEW_DEPLOY_VERSION" \ + --repo "$DEPLOY_REPO" \ + --title "$RELEASE_NAME" \ + --notes "$RELEASE_NAME" \ + "$ARTIFACT_DIR"/linux/*.zip \ + "$ARTIFACT_DIR"/linux-arm/*.zip \ + "$ARTIFACT_DIR"/macos-arm/*.zip \ + "$ARTIFACT_DIR"/macos-intel/*.zip \ + "$ARTIFACT_DIR"/windows/*.zip + + rm -rf "$ARTIFACT_DIR" + ARTIFACT_DIR="" + +fi + +# --- Create PR with updated pyproject.toml --- + +if git rev-parse --verify "$BRANCH" &>/dev/null; then + read -rp "Branch '$BRANCH' already exists locally. Delete it? [y/N] " del_branch + if [[ "${del_branch:-N}" =~ ^[Yy] ]]; then + git branch -D "$BRANCH" + else + echo "Aborting. Delete the branch manually and re-run." + exit 1 + fi +fi + +if git ls-remote --heads --exit-code "$PUSH_REMOTE" "$BRANCH" &>/dev/null; then + echo "Error: branch '$BRANCH' already exists on ${PUSH_REMOTE}." + echo "Delete it remotely first: git push ${PUSH_REMOTE} --delete ${BRANCH}" + exit 1 +fi + +git checkout -b "$BRANCH" + +sed -i.bak "s|scipoptsuite-deploy/releases/download/${CURRENT_DEPLOY_VERSION}|scipoptsuite-deploy/releases/download/${NEW_DEPLOY_VERSION}|g" "$PYPROJECT" +if cmp -s "$PYPROJECT" "${PYPROJECT}.bak"; then + echo "Error: failed to update ${CURRENT_DEPLOY_VERSION} -> ${NEW_DEPLOY_VERSION} in ${PYPROJECT} (pattern not found)." + mv "${PYPROJECT}.bak" "$PYPROJECT" + git checkout master + git branch -D "$BRANCH" + exit 1 +fi +rm -f "${PYPROJECT}.bak" + +git add "$PYPROJECT" +git commit -m "Update scipoptsuite-deploy to ${NEW_DEPLOY_VERSION} (SCIP ${SCIP_VERSION})" +git push -u "$PUSH_REMOTE" "$BRANCH" + +gh pr create --repo "$REPO" \ + --title "Upgrade to SCIP ${SCIP_VERSION}" \ + --body "$(cat < ${NEW_DEPLOY_VERSION} (SCIP ${SCIP_VERSION}, SoPlex ${SOPLEX_VERSION}, GCG ${GCG_VERSION}, IPOPT ${IPOPT_VERSION}). + +## Checklist +- [ ] Fix any API incompatibilities +- [ ] CI is green +- [ ] Update [compatibility table](https://pyscipopt.readthedocs.io/en/latest/build.html#building-from-source) if needed +- [ ] Merge and run \`./release.sh\` +EOF +)" + +echo "" +echo "Done! PR created on branch '${BRANCH}'." +echo "Note: you are now on branch '${BRANCH}'. Switch back with: git checkout master" +echo "Fix any API incompatibilities, get CI green, then merge and run ./release.sh"