diff --git a/RELEASE.md b/RELEASE.md index 88d763a32..8b7260c1d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,19 +1,36 @@ # 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 -```bash -pip install -i https://test.pypi.org/simple/ PySCIPOpt -``` -- [ ] 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) -```bash -git tag vX.X.X -git push origin vX.X.X -``` -- [ ] 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) + +## Upgrading SCIP + +Run `./upgrade_scip.sh` from the `master` branch. 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 + +Run `./release.sh` from the `master` branch. The script will: +1. Prompt for the version bump type (patch/minor/major) +2. Update `_version.py`, `setup.py`, and `CHANGELOG.md` +3. Commit, tag, push, and trigger a test-pypi build + +After the script completes: +- [ ] Test the package from test-pypi: + ```bash + pip install -i https://test.pypi.org/simple/ PySCIPOpt==X.Y.Z + ``` +- [ ] Release to production pypi: + ```bash + gh workflow run build_wheels.yml --repo scipopt/PySCIPOpt -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..91b59d5a3 --- /dev/null +++ b/release.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION_FILE="src/pyscipopt/_version.py" +SETUP_FILE="setup.py" +CHANGELOG="CHANGELOG.md" +REPO="scipopt/PySCIPOpt" + +# --- 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 + echo "Error: must be on 'master' branch (currently on '${CURRENT_BRANCH}')." + exit 1 +fi + +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 +} + +# --- 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}" + +# --- Prompt for bump type --- + +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 + +# --- 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 origin "refs/tags/v${NEW_VERSION}" &>/dev/null; then + echo "Error: tag 'v${NEW_VERSION}' already exists on origin." + 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 "-----------------------------" + +TODAY=$(date +%Y.%m.%d) +echo "" +echo "This script will:" +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, tag v${NEW_VERSION}, and push to origin" +echo " 4. Trigger the build wheels workflow (test-pypi)" +echo "" +read -rp "Proceed? [Y/n] " confirm +[[ "${confirm:-Y}" =~ ^[Nn] ]] && exit 0 + +# ============================================================ +# From here on, everything runs without further prompts. +# If a step fails after commit but before push, recover with: +# git tag -d v${NEW_VERSION} && git reset --soft HEAD~1 +# ============================================================ + +# --- 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" +rm -f "${CHANGELOG}.bak" + +echo "Updated CHANGELOG.md" + +# --- Commit, tag, and push --- + +git add "$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG" +git commit -m "release v${NEW_VERSION}" +git tag "v${NEW_VERSION}" +git push origin master +git push origin "v${NEW_VERSION}" + +# --- Trigger test-pypi build --- + +gh workflow run build_wheels.yml --repo "$REPO" -f upload_to_pypi=true -f test_pypi=true + +echo "" +echo "Done! v${NEW_VERSION} committed, tagged, pushed, and test-pypi build triggered." +echo "Monitor at: gh run list --workflow=build_wheels.yml --repo ${REPO}" +echo "" +echo "Remaining manual steps:" +echo " 1. Test the test-pypi package:" +echo " pip install -i https://test.pypi.org/simple/ PySCIPOpt==${NEW_VERSION}" +echo " 2. Release to production pypi:" +echo " gh workflow run build_wheels.yml --repo ${REPO} -f upload_to_pypi=true -f test_pypi=false" +echo " 3. Create a GitHub release from tag v${NEW_VERSION}:" +echo " gh release create v${NEW_VERSION} --repo ${REPO} --title v${NEW_VERSION} --generate-notes" +echo " 4. Update readthedocs: Builds -> Build version (latest and stable)" diff --git a/upgrade_scip.sh b/upgrade_scip.sh new file mode 100755 index 000000000..1952afb09 --- /dev/null +++ b/upgrade_scip.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +PYPROJECT="pyproject.toml" +DEPLOY_REPO="scipopt/scipoptsuite-deploy" +REPO="scipopt/PySCIPOpt" + +# --- 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 + echo "Error: must be on 'master' branch (currently on '${CURRENT_BRANCH}')." + exit 1 +fi + +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 +} + +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|.*/||') + +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}" + + if [[ ! "$NEW_DEPLOY_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: deploy tag must match vX.Y.Z" + exit 1 + fi + + 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 + +# --- Show summary and confirm --- + +BRANCH="upgrade-scip-${SCIP_VERSION}" + +echo "" +echo "This script will:" +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 "" +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 + for i in {1..12}; do + sleep 5 + RUN_ID=$(gh run list --workflow=build_binaries.yml --repo "$DEPLOY_REPO" --limit 1 --event workflow_dispatch --json databaseId,createdAt --jq "[.[] | select(.createdAt >= \"${DISPATCH_TIME}\")] | .[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" + + 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. 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 + +git checkout -b "$BRANCH" + +sed -i.bak "s|scipoptsuite-deploy/releases/download/${CURRENT_DEPLOY_VERSION}|scipoptsuite-deploy/releases/download/${NEW_DEPLOY_VERSION}|g" "$PYPROJECT" +rm -f "${PYPROJECT}.bak" + +git add "$PYPROJECT" +git commit -m "Update scipoptsuite-deploy to ${NEW_DEPLOY_VERSION} (SCIP ${SCIP_VERSION})" +git push -u origin "$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"