From 5a5189f0d9b7bc8082328f861ff7ce17c9601f15 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Fri, 3 Apr 2026 12:29:25 +0100 Subject: [PATCH 01/15] Add release automation script --- release.sh | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100755 release.sh diff --git a/release.sh b/release.sh new file mode 100755 index 000000000..8981a3778 --- /dev/null +++ b/release.sh @@ -0,0 +1,138 @@ +#!/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 ! git diff --quiet || ! git diff --cached --quiet; then + echo "Error: working directory has uncommitted changes. Commit or stash them 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 + +# --- Read current version --- + +CURRENT_VERSION=$(sed -n "s/^__version__.*'\(.*\)'/\1/p" "$VERSION_FILE") +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." + exit 1 +fi + +# --- Show changelog preview --- + +echo "" +echo "Unreleased changelog entries:" +echo "-----------------------------" +# Print lines between "## Unreleased" and the next "## " header +sed -n '/^## Unreleased$/,/^## [0-9]/{/^## [0-9]/!p;}' "$CHANGELOG" | head -30 +echo "-----------------------------" +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 + +# --- Update version files --- + +sed -i.bak "s/__version__.*=.*'.*'/__version__: str = '${NEW_VERSION}'/" "$VERSION_FILE" +rm -f "${VERSION_FILE}.bak" + +sed -i.bak "s/version=\"${CURRENT_VERSION}\"/version=\"${NEW_VERSION}\"/" "$SETUP_FILE" +rm -f "${SETUP_FILE}.bak" + +echo "Updated version: ${CURRENT_VERSION} -> ${NEW_VERSION}" + +# --- Update changelog --- + +UNRELEASED_HEADER="## Unreleased" +NEW_HEADER="## ${NEW_VERSION} - ${TODAY}" +EMPTY_UNRELEASED="## Unreleased\n### Added\n### Fixed\n### Changed\n### Removed\n" + +sed -i.bak "s/^${UNRELEASED_HEADER}$/${NEW_HEADER}/" "$CHANGELOG" +rm -f "${CHANGELOG}.bak" + +# Add empty Unreleased section at the top (after "# CHANGELOG" line) +sed -i.bak "/^# CHANGELOG$/a\\ +\\ +${EMPTY_UNRELEASED}" "$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)" From a061adc07e2a7661c4fdf4c249d339870e683d0e Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Fri, 3 Apr 2026 12:45:40 +0100 Subject: [PATCH 02/15] Add scipoptsuite-deploy integration to release script --- release.sh | 160 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 138 insertions(+), 22 deletions(-) diff --git a/release.sh b/release.sh index 8981a3778..4283b9e27 100755 --- a/release.sh +++ b/release.sh @@ -3,8 +3,10 @@ set -euo pipefail VERSION_FILE="src/pyscipopt/_version.py" SETUP_FILE="setup.py" +PYPROJECT="pyproject.toml" CHANGELOG="CHANGELOG.md" REPO="scipopt/PySCIPOpt" +DEPLOY_REPO="scipopt/scipoptsuite-deploy" # --- Pre-flight checks --- @@ -31,17 +33,74 @@ fi git pull --ff-only -# --- Read current version --- +# --- 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 --- + +# 1. New SCIP binaries? + +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}" +read -rp "Does this release need new SCIP binaries? [y/N] " need_deploy + +NEED_DEPLOY=false +if [[ "${need_deploy:-N}" =~ ^[Yy] ]]; then + NEED_DEPLOY=true + + 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 -d) + 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") + + # 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}" +fi + +# 2. PySCIPOpt version bump CURRENT_VERSION=$(sed -n "s/^__version__.*'\(.*\)'/\1/p" "$VERSION_FILE") 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 "Current PySCIPOpt version: ${CURRENT_VERSION}" echo "" echo "Release type:" echo " 1) patch -> $((MAJOR)).$((MINOR)).$((PATCH + 1))" @@ -57,34 +116,88 @@ case "$bump_type" in *) 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." exit 1 fi -# --- Show changelog preview --- +# --- Show summary and confirm --- echo "" echo "Unreleased changelog entries:" echo "-----------------------------" -# Print lines between "## Unreleased" and the next "## " header sed -n '/^## Unreleased$/,/^## [0-9]/{/^## [0-9]/!p;}' "$CHANGELOG" | head -30 echo "-----------------------------" -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)" +if [[ "$NEED_DEPLOY" == true ]]; 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. Update deploy version ${CURRENT_DEPLOY_VERSION} -> ${NEW_DEPLOY_VERSION} in pyproject.toml" + echo " 4. Update PySCIPOpt version ${CURRENT_VERSION} -> ${NEW_VERSION} in _version.py and setup.py" + echo " 5. Update CHANGELOG.md (${NEW_VERSION} - ${TODAY})" + echo " 6. Commit, tag v${NEW_VERSION}, and push to origin" + echo " 7. Trigger the build wheels workflow (test-pypi)" +else + 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)" +fi echo "" read -rp "Proceed? [Y/n] " confirm [[ "${confirm:-Y}" =~ ^[Nn] ]] && exit 0 +# ============================================================ +# From here on, everything runs without further prompts. +# ============================================================ + +# --- Build and release SCIP binaries (if needed) --- + +if [[ "$NEED_DEPLOY" == true ]]; then + echo "" + echo "Triggering SCIP binary build..." + 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 + sleep 5 + RUN_ID=$(gh run list --workflow=build_binaries.yml --repo "$DEPLOY_REPO" --limit 1 --json databaseId --jq '.[0].databaseId') + + 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 + + # Download artifacts and create release + TMPDIR=$(mktemp -d) + echo "Downloading artifacts..." + gh run download "$RUN_ID" --repo "$DEPLOY_REPO" --dir "$TMPDIR" + + 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" \ + "$TMPDIR"/linux/*.zip \ + "$TMPDIR"/linux-arm/*.zip \ + "$TMPDIR"/macos-arm/*.zip \ + "$TMPDIR"/macos-intel/*.zip \ + "$TMPDIR"/windows/*.zip + + rm -rf "$TMPDIR" + + # Update deploy version in pyproject.toml + sed -i.bak "s|scipoptsuite-deploy/releases/download/${CURRENT_DEPLOY_VERSION}|scipoptsuite-deploy/releases/download/${NEW_DEPLOY_VERSION}|g" "$PYPROJECT" + rm -f "${PYPROJECT}.bak" + echo "Updated pyproject.toml: ${CURRENT_DEPLOY_VERSION} -> ${NEW_DEPLOY_VERSION}" +fi + # --- Update version files --- sed -i.bak "s/__version__.*=.*'.*'/__version__: str = '${NEW_VERSION}'/" "$VERSION_FILE" @@ -97,24 +210,27 @@ echo "Updated version: ${CURRENT_VERSION} -> ${NEW_VERSION}" # --- Update changelog --- -UNRELEASED_HEADER="## Unreleased" -NEW_HEADER="## ${NEW_VERSION} - ${TODAY}" -EMPTY_UNRELEASED="## Unreleased\n### Added\n### Fixed\n### Changed\n### Removed\n" - -sed -i.bak "s/^${UNRELEASED_HEADER}$/${NEW_HEADER}/" "$CHANGELOG" +sed -i.bak "s/^## Unreleased$/## ${NEW_VERSION} - ${TODAY}/" "$CHANGELOG" rm -f "${CHANGELOG}.bak" -# Add empty Unreleased section at the top (after "# CHANGELOG" line) sed -i.bak "/^# CHANGELOG$/a\\ \\ -${EMPTY_UNRELEASED}" "$CHANGELOG" +## 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" +FILES_TO_COMMIT=("$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG") +[[ "$NEED_DEPLOY" == true ]] && FILES_TO_COMMIT+=("$PYPROJECT") + +git add "${FILES_TO_COMMIT[@]}" git commit -m "release v${NEW_VERSION}" git tag "v${NEW_VERSION}" git push origin master From 8501bec108ebd2510669a3b393041b5f021dce11 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Fri, 3 Apr 2026 12:55:30 +0100 Subject: [PATCH 03/15] Address review: clean dir check, version validation, remote tag check --- release.sh | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/release.sh b/release.sh index 4283b9e27..7947fd7e7 100755 --- a/release.sh +++ b/release.sh @@ -20,8 +20,8 @@ if ! gh auth status &>/dev/null; then exit 1 fi -if ! git diff --quiet || ! git diff --cached --quiet; then - echo "Error: working directory has uncommitted changes. Commit or stash them first." +if [[ -n "$(git status --porcelain)" ]]; then + echo "Error: working directory is not clean. Commit, stash, or remove changes first." exit 1 fi @@ -67,7 +67,7 @@ if [[ "${need_deploy:-N}" =~ ^[Yy] ]]; then 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 -d) + 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 } @@ -90,11 +90,22 @@ if [[ "${need_deploy:-N}" =~ ^[Yy] ]]; then 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 api "repos/${DEPLOY_REPO}/git/ref/tags/${NEW_DEPLOY_VERSION}" &>/dev/null; then + echo "Error: deploy tag ${NEW_DEPLOY_VERSION} already exists in ${DEPLOY_REPO}." + exit 1 + fi fi # 2. PySCIPOpt version bump 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) @@ -117,7 +128,12 @@ case "$bump_type" in esac if git rev-parse "v${NEW_VERSION}" &>/dev/null; then - echo "Error: tag 'v${NEW_VERSION}' already exists." + 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 @@ -166,8 +182,17 @@ if [[ "$NEED_DEPLOY" == true ]]; then -f ipopt_version="$IPOPT_VERSION" # Wait for the run to appear - sleep 5 - RUN_ID=$(gh run list --workflow=build_binaries.yml --repo "$DEPLOY_REPO" --limit 1 --json databaseId --jq '.[0].databaseId') + DISPATCH_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) + 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}" From 73d63cfda152822e3b925f0da588e81a1c790382 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Fri, 3 Apr 2026 15:06:54 +0100 Subject: [PATCH 04/15] Split deploy into upgrade_scip.sh, simplify release.sh --- release.sh | 148 ++++------------------------------------ upgrade_scip.sh | 176 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 136 deletions(-) create mode 100755 upgrade_scip.sh diff --git a/release.sh b/release.sh index 7947fd7e7..6035871aa 100755 --- a/release.sh +++ b/release.sh @@ -3,10 +3,8 @@ set -euo pipefail VERSION_FILE="src/pyscipopt/_version.py" SETUP_FILE="setup.py" -PYPROJECT="pyproject.toml" CHANGELOG="CHANGELOG.md" REPO="scipopt/PySCIPOpt" -DEPLOY_REPO="scipopt/scipoptsuite-deploy" # --- Pre-flight checks --- @@ -42,67 +40,7 @@ validate_version() { fi } -prompt_version() { - local label="$1" current="$2" - read -rp "${label} [${current}]: " value - value="${value:-$current}" - validate_version "$value" - echo "$value" -} - -# --- Collect all inputs --- - -# 1. New SCIP binaries? - -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}" -read -rp "Does this release need new SCIP binaries? [y/N] " need_deploy - -NEED_DEPLOY=false -if [[ "${need_deploy:-N}" =~ ^[Yy] ]]; then - NEED_DEPLOY=true - - 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") - - # 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 api "repos/${DEPLOY_REPO}/git/ref/tags/${NEW_DEPLOY_VERSION}" &>/dev/null; then - echo "Error: deploy tag ${NEW_DEPLOY_VERSION} already exists in ${DEPLOY_REPO}." - exit 1 - fi -fi - -# 2. PySCIPOpt version bump +# --- Read current version --- CURRENT_VERSION=$(sed -n "s/^__version__.*'\(.*\)'/\1/p" "$VERSION_FILE") validate_version "$CURRENT_VERSION" @@ -110,8 +48,10 @@ MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) -echo "" -echo "Current PySCIPOpt version: ${CURRENT_VERSION}" +echo "Current version: ${CURRENT_VERSION}" + +# --- Prompt for bump type --- + echo "" echo "Release type:" echo " 1) patch -> $((MAJOR)).$((MINOR)).$((PATCH + 1))" @@ -127,6 +67,8 @@ case "$bump_type" in *) 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 @@ -148,20 +90,10 @@ echo "-----------------------------" TODAY=$(date +%Y.%m.%d) echo "" echo "This script will:" -if [[ "$NEED_DEPLOY" == true ]]; 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. Update deploy version ${CURRENT_DEPLOY_VERSION} -> ${NEW_DEPLOY_VERSION} in pyproject.toml" - echo " 4. Update PySCIPOpt version ${CURRENT_VERSION} -> ${NEW_VERSION} in _version.py and setup.py" - echo " 5. Update CHANGELOG.md (${NEW_VERSION} - ${TODAY})" - echo " 6. Commit, tag v${NEW_VERSION}, and push to origin" - echo " 7. Trigger the build wheels workflow (test-pypi)" -else - 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)" -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, 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 @@ -170,59 +102,6 @@ read -rp "Proceed? [Y/n] " confirm # From here on, everything runs without further prompts. # ============================================================ -# --- Build and release SCIP binaries (if needed) --- - -if [[ "$NEED_DEPLOY" == true ]]; then - echo "" - echo "Triggering SCIP binary build..." - 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 - DISPATCH_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) - 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 - - # Download artifacts and create release - TMPDIR=$(mktemp -d) - echo "Downloading artifacts..." - gh run download "$RUN_ID" --repo "$DEPLOY_REPO" --dir "$TMPDIR" - - 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" \ - "$TMPDIR"/linux/*.zip \ - "$TMPDIR"/linux-arm/*.zip \ - "$TMPDIR"/macos-arm/*.zip \ - "$TMPDIR"/macos-intel/*.zip \ - "$TMPDIR"/windows/*.zip - - rm -rf "$TMPDIR" - - # Update deploy version in pyproject.toml - sed -i.bak "s|scipoptsuite-deploy/releases/download/${CURRENT_DEPLOY_VERSION}|scipoptsuite-deploy/releases/download/${NEW_DEPLOY_VERSION}|g" "$PYPROJECT" - rm -f "${PYPROJECT}.bak" - echo "Updated pyproject.toml: ${CURRENT_DEPLOY_VERSION} -> ${NEW_DEPLOY_VERSION}" -fi - # --- Update version files --- sed -i.bak "s/__version__.*=.*'.*'/__version__: str = '${NEW_VERSION}'/" "$VERSION_FILE" @@ -252,10 +131,7 @@ echo "Updated CHANGELOG.md" # --- Commit, tag, and push --- -FILES_TO_COMMIT=("$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG") -[[ "$NEED_DEPLOY" == true ]] && FILES_TO_COMMIT+=("$PYPROJECT") - -git add "${FILES_TO_COMMIT[@]}" +git add "$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG" git commit -m "release v${NEW_VERSION}" git tag "v${NEW_VERSION}" git push origin master diff --git a/upgrade_scip.sh b/upgrade_scip.sh new file mode 100755 index 000000000..8a7c11e78 --- /dev/null +++ b/upgrade_scip.sh @@ -0,0 +1,176 @@ +#!/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") + +# 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 api "repos/${DEPLOY_REPO}/git/ref/tags/${NEW_DEPLOY_VERSION}" &>/dev/null; then + echo "Error: deploy tag ${NEW_DEPLOY_VERSION} already exists in ${DEPLOY_REPO}." + exit 1 +fi + +# --- Show summary and confirm --- + +BRANCH="upgrade-scip-${SCIP_VERSION}" + +echo "" +echo "This script will:" +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" +echo "" +read -rp "Proceed? [Y/n] " confirm +[[ "${confirm:-Y}" =~ ^[Nn] ]] && exit 0 + +# ============================================================ +# From here on, everything runs without further prompts. +# ============================================================ + +# --- Build SCIP binaries --- + +echo "" +echo "Triggering SCIP binary build..." +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 +DISPATCH_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) +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 --- + +TMPDIR=$(mktemp -d) +echo "Downloading artifacts..." +gh run download "$RUN_ID" --repo "$DEPLOY_REPO" --dir "$TMPDIR" + +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" \ + "$TMPDIR"/linux/*.zip \ + "$TMPDIR"/linux-arm/*.zip \ + "$TMPDIR"/macos-arm/*.zip \ + "$TMPDIR"/macos-intel/*.zip \ + "$TMPDIR"/windows/*.zip + +rm -rf "$TMPDIR" + +# --- Create PR with updated pyproject.toml --- + +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 "Updates scipoptsuite-deploy ${CURRENT_DEPLOY_VERSION} -> ${NEW_DEPLOY_VERSION} (SCIP ${SCIP_VERSION}, SoPlex ${SOPLEX_VERSION}, GCG ${GCG_VERSION}, IPOPT ${IPOPT_VERSION}). + +Fix any API incompatibilities, get CI green, then merge and run \`./release.sh\`." + +echo "" +echo "Done! PR created on branch '${BRANCH}'." +echo "Fix any API incompatibilities, get CI green, then merge and run ./release.sh" From bbb3e2249770b3f1e0160af80b801e14cd56d563 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Thu, 9 Apr 2026 16:00:45 +0100 Subject: [PATCH 05/15] Harden release scripts: validate sed replacements, fix dispatch race, cleanup on failure --- release.sh | 17 +++++++++++++++++ upgrade_scip.sh | 33 ++++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/release.sh b/release.sh index 6035871aa..91b59d5a3 100755 --- a/release.sh +++ b/release.sh @@ -100,14 +100,26 @@ read -rp "Proceed? [Y/n] " confirm # ============================================================ # 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}" @@ -115,6 +127,11 @@ 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\\ diff --git a/upgrade_scip.sh b/upgrade_scip.sh index 8a7c11e78..b618f2e7c 100755 --- a/upgrade_scip.sh +++ b/upgrade_scip.sh @@ -107,10 +107,15 @@ read -rp "Proceed? [Y/n] " confirm # From here on, everything runs without further prompts. # ============================================================ +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" \ @@ -118,7 +123,6 @@ gh workflow run build_binaries.yml --repo "$DEPLOY_REPO" \ -f ipopt_version="$IPOPT_VERSION" # Wait for the run to appear -DISPATCH_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) 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") @@ -136,9 +140,9 @@ gh run watch "$RUN_ID" --repo "$DEPLOY_REPO" --exit-status # --- Create deploy release --- -TMPDIR=$(mktemp -d) +ARTIFACT_DIR=$(mktemp -d) echo "Downloading artifacts..." -gh run download "$RUN_ID" --repo "$DEPLOY_REPO" --dir "$TMPDIR" +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}..." @@ -146,16 +150,26 @@ gh release create "$NEW_DEPLOY_VERSION" \ --repo "$DEPLOY_REPO" \ --title "$RELEASE_NAME" \ --notes "$RELEASE_NAME" \ - "$TMPDIR"/linux/*.zip \ - "$TMPDIR"/linux-arm/*.zip \ - "$TMPDIR"/macos-arm/*.zip \ - "$TMPDIR"/macos-intel/*.zip \ - "$TMPDIR"/windows/*.zip + "$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 "$TMPDIR" +rm -rf "$ARTIFACT_DIR" # --- 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" @@ -173,4 +187,5 @@ Fix any API incompatibilities, get CI green, then merge and run \`./release.sh\` 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" From 0096ff32167f42a8604dee96ae9e92300151624a Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Thu, 9 Apr 2026 16:24:50 +0100 Subject: [PATCH 06/15] Skip deploy build when release already exists --- upgrade_scip.sh | 148 ++++++++++++++++++++++++++++-------------------- 1 file changed, 86 insertions(+), 62 deletions(-) diff --git a/upgrade_scip.sh b/upgrade_scip.sh index b618f2e7c..8787dc5df 100755 --- a/upgrade_scip.sh +++ b/upgrade_scip.sh @@ -71,23 +71,36 @@ SOPLEX_VERSION=$(prompt_version "SoPlex" "$CUR_SOPLEX") GCG_VERSION=$(prompt_version "GCG" "$CUR_GCG") IPOPT_VERSION=$(prompt_version "IPOPT" "$CUR_IPOPT") -# 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))" +# --- Check if a matching deploy release already exists --- -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 +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 api "repos/${DEPLOY_REPO}/git/ref/tags/${NEW_DEPLOY_VERSION}" &>/dev/null; then - echo "Error: deploy tag ${NEW_DEPLOY_VERSION} already exists in ${DEPLOY_REPO}." - exit 1 + 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 --- @@ -96,9 +109,15 @@ BRANCH="upgrade-scip-${SCIP_VERSION}" echo "" echo "This script will:" -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" +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 @@ -107,56 +126,61 @@ read -rp "Proceed? [Y/n] " confirm # From here on, everything runs without further prompts. # ============================================================ -ARTIFACT_DIR="" -cleanup() { [[ -n "$ARTIFACT_DIR" ]] && rm -rf "$ARTIFACT_DIR"; } -trap cleanup EXIT +if [[ "$SKIP_DEPLOY" == false ]]; then -# --- Build SCIP binaries --- + ARTIFACT_DIR="" + cleanup() { [[ -n "$ARTIFACT_DIR" ]] && rm -rf "$ARTIFACT_DIR"; } + trap cleanup EXIT -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 + # --- 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" -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 + # 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 -# --- Create deploy release --- + if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then + echo "Error: could not find the triggered workflow run." + exit 1 + fi -ARTIFACT_DIR=$(mktemp -d) -echo "Downloading artifacts..." -gh run download "$RUN_ID" --repo "$DEPLOY_REPO" --dir "$ARTIFACT_DIR" + 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 -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" + # --- 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 --- From e43be806512d6167be256419969ce0e25fa94a14 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Thu, 9 Apr 2026 16:42:31 +0100 Subject: [PATCH 07/15] Add PR checklist and update RELEASE.md for automated workflow --- RELEASE.md | 53 ++++++++++++++++++++++++++++++++----------------- upgrade_scip.sh | 13 +++++++++--- 2 files changed, 45 insertions(+), 21 deletions(-) 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/upgrade_scip.sh b/upgrade_scip.sh index 8787dc5df..1952afb09 100755 --- a/upgrade_scip.sh +++ b/upgrade_scip.sh @@ -205,9 +205,16 @@ git push -u origin "$BRANCH" gh pr create --repo "$REPO" \ --title "Upgrade to SCIP ${SCIP_VERSION}" \ - --body "Updates scipoptsuite-deploy ${CURRENT_DEPLOY_VERSION} -> ${NEW_DEPLOY_VERSION} (SCIP ${SCIP_VERSION}, SoPlex ${SOPLEX_VERSION}, GCG ${GCG_VERSION}, IPOPT ${IPOPT_VERSION}). - -Fix any API incompatibilities, get CI green, then merge and run \`./release.sh\`." + --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}'." From af99db4e2fd06edb2c4d2325167cc29888230636 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 22 Apr 2026 16:38:49 +0100 Subject: [PATCH 08/15] Harden release scripts: idempotency check, pyproject sed guard, remote-aware push --- release.sh | 38 ++++++++++++++++++++++++------ upgrade_scip.sh | 61 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/release.sh b/release.sh index 91b59d5a3..17c53e407 100755 --- a/release.sh +++ b/release.sh @@ -29,6 +29,8 @@ if [[ "$CURRENT_BRANCH" != "master" ]]; then exit 1 fi +PUSH_REMOTE=$(git config --get "branch.${CURRENT_BRANCH}.remote" 2>/dev/null || echo origin) + git pull --ff-only # --- Helper functions --- @@ -48,6 +50,20 @@ MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) +# Detect partial failure: if HEAD is already a release commit for this version, +# a previous run bumped+committed but did not finish. Re-bumping would double the version. +if [[ "$(git log -1 --format=%s)" == "release v${CURRENT_VERSION}" ]]; then + echo "Error: HEAD is already 'release v${CURRENT_VERSION}' — a prior run did not finish." + echo "" + echo "Recovery options:" + echo " a) Nothing was pushed: git tag -d v${CURRENT_VERSION} 2>/dev/null; git reset --hard HEAD~1" + echo " b) Master pushed, tag not: git push ${PUSH_REMOTE} v${CURRENT_VERSION} && \\" + echo " gh workflow run build_wheels.yml --repo ${REPO} -f upload_to_pypi=true -f test_pypi=true" + echo " c) Everything pushed, workflow not triggered:" + echo " gh workflow run build_wheels.yml --repo ${REPO} -f upload_to_pypi=true -f test_pypi=true" + exit 1 +fi + echo "Current version: ${CURRENT_VERSION}" # --- Prompt for bump type --- @@ -74,8 +90,8 @@ if git rev-parse "v${NEW_VERSION}" &>/dev/null; then 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." +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 @@ -87,12 +103,20 @@ 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) 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 " 3. Commit, tag v${NEW_VERSION}, and push to ${PUSH_REMOTE}" echo " 4. Trigger the build wheels workflow (test-pypi)" echo "" read -rp "Proceed? [Y/n] " confirm @@ -100,8 +124,8 @@ read -rp "Proceed? [Y/n] " confirm # ============================================================ # 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 +# 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 --- @@ -151,8 +175,8 @@ echo "Updated CHANGELOG.md" 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}" +git push "$PUSH_REMOTE" "$CURRENT_BRANCH" +git push "$PUSH_REMOTE" "v${NEW_VERSION}" # --- Trigger test-pypi build --- diff --git a/upgrade_scip.sh b/upgrade_scip.sh index 1952afb09..5672a0295 100755 --- a/upgrade_scip.sh +++ b/upgrade_scip.sh @@ -28,6 +28,8 @@ if [[ "$CURRENT_BRANCH" != "master" ]]; then exit 1 fi +PUSH_REMOTE=$(git config --get "branch.${CURRENT_BRANCH}.remote" 2>/dev/null || echo origin) + git pull --ff-only # --- Helper functions --- @@ -39,6 +41,13 @@ validate_version() { 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 @@ -51,6 +60,12 @@ prompt_version() { 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):" @@ -92,10 +107,7 @@ else 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 + 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)." @@ -103,6 +115,11 @@ else 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}" @@ -143,10 +160,15 @@ if [[ "$SKIP_DEPLOY" == false ]]; then -f gcg_version="$GCG_VERSION" \ -f ipopt_version="$IPOPT_VERSION" - # Wait for the run to appear + # 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 1 --event workflow_dispatch --json databaseId,createdAt --jq "[.[] | select(.createdAt >= \"${DISPATCH_TIME}\")] | .[0].databaseId") + 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 @@ -165,6 +187,16 @@ if [[ "$SKIP_DEPLOY" == false ]]; then 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" \ @@ -185,7 +217,7 @@ 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 + read -rp "Branch '$BRANCH' already exists locally. Delete it? [y/N] " del_branch if [[ "${del_branch:-N}" =~ ^[Yy] ]]; then git branch -D "$BRANCH" else @@ -194,14 +226,27 @@ if git rev-parse --verify "$BRANCH" &>/dev/null; then 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 origin "$BRANCH" +git push -u "$PUSH_REMOTE" "$BRANCH" gh pr create --repo "$REPO" \ --title "Upgrade to SCIP ${SCIP_VERSION}" \ From adc1d087bf3b785077c13416afed7024182e5024 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 22 Apr 2026 16:51:57 +0100 Subject: [PATCH 09/15] Add --dry-run, pin wheel build to tag via --ref, guard CHANGELOG insert --- RELEASE.md | 6 +++--- release.sh | 53 ++++++++++++++++++++++++++++++++++++++++++------- upgrade_scip.sh | 33 +++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 8b7260c1d..af298801e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,7 +2,7 @@ ## Upgrading SCIP -Run `./upgrade_scip.sh` from the `master` branch. The script will: +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 @@ -15,7 +15,7 @@ On the PR: ## Releasing PySCIPOpt -Run `./release.sh` from the `master` branch. The script will: +Run `./release.sh` from the `master` branch (use `--dry-run` first to preview without side effects). 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 @@ -27,7 +27,7 @@ After the script completes: ``` - [ ] Release to production pypi: ```bash - gh workflow run build_wheels.yml --repo scipopt/PySCIPOpt -f upload_to_pypi=true -f test_pypi=false + 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 diff --git a/release.sh b/release.sh index 17c53e407..4f0f28392 100755 --- a/release.sh +++ b/release.sh @@ -6,6 +6,15 @@ SETUP_FILE="setup.py" CHANGELOG="CHANGELOG.md" 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 @@ -58,9 +67,9 @@ if [[ "$(git log -1 --format=%s)" == "release v${CURRENT_VERSION}" ]]; then echo "Recovery options:" echo " a) Nothing was pushed: git tag -d v${CURRENT_VERSION} 2>/dev/null; git reset --hard HEAD~1" echo " b) Master pushed, tag not: git push ${PUSH_REMOTE} v${CURRENT_VERSION} && \\" - echo " gh workflow run build_wheels.yml --repo ${REPO} -f upload_to_pypi=true -f test_pypi=true" + echo " gh workflow run build_wheels.yml --repo ${REPO} --ref v${CURRENT_VERSION} -f upload_to_pypi=true -f test_pypi=true" echo " c) Everything pushed, workflow not triggered:" - echo " gh workflow run build_wheels.yml --repo ${REPO} -f upload_to_pypi=true -f test_pypi=true" + echo " gh workflow run build_wheels.yml --repo ${REPO} --ref v${CURRENT_VERSION} -f upload_to_pypi=true -f test_pypi=true" exit 1 fi @@ -113,14 +122,20 @@ fi TODAY=$(date +%Y.%m.%d) echo "" -echo "This script will:" +if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: This script would:" +else + echo "This script will:" +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, tag v${NEW_VERSION}, and push to ${PUSH_REMOTE}" echo " 4. Trigger the build wheels workflow (test-pypi)" echo "" -read -rp "Proceed? [Y/n] " confirm -[[ "${confirm:-Y}" =~ ^[Nn] ]] && exit 0 +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. @@ -166,10 +181,32 @@ sed -i.bak "/^# CHANGELOG$/a\\ ### 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, tag, 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 tag v${NEW_VERSION}" + echo " git push ${PUSH_REMOTE} ${CURRENT_BRANCH}" + echo " git push ${PUSH_REMOTE} v${NEW_VERSION}" + echo " gh workflow run build_wheels.yml --repo ${REPO} --ref v${NEW_VERSION} -f upload_to_pypi=true -f test_pypi=true" + exit 0 +fi + # --- Commit, tag, and push --- git add "$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG" @@ -179,8 +216,10 @@ git push "$PUSH_REMOTE" "$CURRENT_BRANCH" git push "$PUSH_REMOTE" "v${NEW_VERSION}" # --- Trigger test-pypi build --- +# --ref pins the build to the tag we just pushed so a race with a master push +# can't cause the wheel to be built from a different commit than the tag. -gh workflow run build_wheels.yml --repo "$REPO" -f upload_to_pypi=true -f test_pypi=true +gh workflow run build_wheels.yml --repo "$REPO" --ref "v${NEW_VERSION}" -f upload_to_pypi=true -f test_pypi=true echo "" echo "Done! v${NEW_VERSION} committed, tagged, pushed, and test-pypi build triggered." @@ -190,7 +229,7 @@ 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 " gh workflow run build_wheels.yml --repo ${REPO} --ref v${NEW_VERSION} -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 index 5672a0295..a0b989bb7 100755 --- a/upgrade_scip.sh +++ b/upgrade_scip.sh @@ -5,6 +5,15 @@ 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 @@ -125,7 +134,11 @@ fi BRANCH="upgrade-scip-${SCIP_VERSION}" echo "" -echo "This script will:" +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}" @@ -136,6 +149,24 @@ else 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 b23395a75a39d57cc510e5b00eb861a9a3ee9b29 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 22 Apr 2026 17:02:57 +0100 Subject: [PATCH 10/15] Allow --dry-run from any branch (real runs still require master) --- release.sh | 8 ++++++-- upgrade_scip.sh | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/release.sh b/release.sh index 4f0f28392..e993f7c4d 100755 --- a/release.sh +++ b/release.sh @@ -34,8 +34,12 @@ 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 + 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) diff --git a/upgrade_scip.sh b/upgrade_scip.sh index a0b989bb7..3341e31d6 100755 --- a/upgrade_scip.sh +++ b/upgrade_scip.sh @@ -33,8 +33,12 @@ 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 + 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) From 68e61a9e0ee96d1b74e83b847db255684735f5c8 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 22 Apr 2026 17:19:29 +0100 Subject: [PATCH 11/15] Split release.sh into two phases via release-candidate branch --- release.sh | 165 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 125 insertions(+), 40 deletions(-) diff --git a/release.sh b/release.sh index e993f7c4d..5739507b7 100755 --- a/release.sh +++ b/release.sh @@ -55,6 +55,108 @@ validate_version() { fi } +# Promote a successful release candidate: tag, push master, clean up RC branch. +finalize_release() { + local version="$1" + local rc_branch="release-candidate-v${version}" + + echo "Pending release candidate: v${version}" + echo "Checking workflow status on ${rc_branch}..." + + local run_data run_count status conclusion url run_id + run_data=$(gh run list --workflow=build_wheels.yml --repo "$REPO" \ + --branch "$rc_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 '${rc_branch}'." + echo "Either wait a moment and try again, or clean up manually:" + echo " git push ${PUSH_REMOTE} --delete ${rc_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 ${rc_branch} + reset local commit)." + exit 0 + fi + read -rp "Roll back (delete ${rc_branch} and reset local release commit)? [Y/n] " confirm + if [[ ! "${confirm:-Y}" =~ ^[Nn] ]]; then + git push "$PUSH_REMOTE" --delete "$rc_branch" || echo "(note: remote RC 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 ${rc_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 "$rc_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)" +} + +# --- Detect pending release candidate --- +# If HEAD is a local release commit and its RC branch exists on origin, we are in +# the second phase (finalize). Otherwise we are starting a new release. + +HEAD_MSG=$(git log -1 --format=%s) +if [[ "$HEAD_MSG" =~ ^release\ v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + PENDING_VERSION="${BASH_REMATCH[1]}" + RC_BRANCH_CHECK="release-candidate-v${PENDING_VERSION}" + if git ls-remote --heads --exit-code "$PUSH_REMOTE" "$RC_BRANCH_CHECK" &>/dev/null; then + finalize_release "$PENDING_VERSION" + exit 0 + else + echo "Error: HEAD is 'release v${PENDING_VERSION}' but no RC branch '${RC_BRANCH_CHECK}' on ${PUSH_REMOTE}." + echo "A previous run may have been interrupted. Recover manually:" + echo " a) Push the RC branch and re-run:" + echo " git push ${PUSH_REMOTE} HEAD:refs/heads/${RC_BRANCH_CHECK}" + echo " b) Or undo the local commit to start over:" + echo " git reset --hard HEAD~1" + exit 1 + fi +fi + # --- Read current version --- CURRENT_VERSION=$(sed -n "s/^__version__.*'\(.*\)'/\1/p" "$VERSION_FILE") @@ -63,20 +165,6 @@ MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) -# Detect partial failure: if HEAD is already a release commit for this version, -# a previous run bumped+committed but did not finish. Re-bumping would double the version. -if [[ "$(git log -1 --format=%s)" == "release v${CURRENT_VERSION}" ]]; then - echo "Error: HEAD is already 'release v${CURRENT_VERSION}' — a prior run did not finish." - echo "" - echo "Recovery options:" - echo " a) Nothing was pushed: git tag -d v${CURRENT_VERSION} 2>/dev/null; git reset --hard HEAD~1" - echo " b) Master pushed, tag not: git push ${PUSH_REMOTE} v${CURRENT_VERSION} && \\" - echo " gh workflow run build_wheels.yml --repo ${REPO} --ref v${CURRENT_VERSION} -f upload_to_pypi=true -f test_pypi=true" - echo " c) Everything pushed, workflow not triggered:" - echo " gh workflow run build_wheels.yml --repo ${REPO} --ref v${CURRENT_VERSION} -f upload_to_pypi=true -f test_pypi=true" - exit 1 -fi - echo "Current version: ${CURRENT_VERSION}" # --- Prompt for bump type --- @@ -125,16 +213,22 @@ if [[ "$UNRELEASED_BULLETS" == "0" ]]; then fi TODAY=$(date +%Y.%m.%d) +RC_BRANCH="release-candidate-v${NEW_VERSION}" echo "" if [[ "$DRY_RUN" == true ]]; then echo "DRY RUN: This script would:" else - echo "This script will:" + 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, tag v${NEW_VERSION}, and push to ${PUSH_REMOTE}" -echo " 4. Trigger the build wheels workflow (test-pypi)" +echo " 3. Commit locally, push commit to branch '${RC_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 RC branch" +echo " - failure -> prompt to roll back (delete RC branch + reset local commit)" echo "" if [[ "$DRY_RUN" == false ]]; then read -rp "Proceed? [Y/n] " confirm @@ -199,41 +293,32 @@ if [[ "$DRY_RUN" == true ]]; then echo "DRY RUN: planned file changes:" git --no-pager diff -- "$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG" echo "" - echo "DRY RUN: reverting local edits (no commit, tag, push, or workflow trigger)." + 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 tag v${NEW_VERSION}" - echo " git push ${PUSH_REMOTE} ${CURRENT_BRANCH}" - echo " git push ${PUSH_REMOTE} v${NEW_VERSION}" - echo " gh workflow run build_wheels.yml --repo ${REPO} --ref v${NEW_VERSION} -f upload_to_pypi=true -f test_pypi=true" + echo " git push ${PUSH_REMOTE} HEAD:refs/heads/${RC_BRANCH}" + echo " gh workflow run build_wheels.yml --repo ${REPO} --ref ${RC_BRANCH} -f upload_to_pypi=true -f test_pypi=true" exit 0 fi -# --- Commit, tag, and push --- +# --- Commit locally and push RC branch (no tag, no master push yet) --- git add "$VERSION_FILE" "$SETUP_FILE" "$CHANGELOG" git commit -m "release v${NEW_VERSION}" -git tag "v${NEW_VERSION}" -git push "$PUSH_REMOTE" "$CURRENT_BRANCH" -git push "$PUSH_REMOTE" "v${NEW_VERSION}" +git push "$PUSH_REMOTE" "HEAD:refs/heads/${RC_BRANCH}" -# --- Trigger test-pypi build --- -# --ref pins the build to the tag we just pushed so a race with a master push -# can't cause the wheel to be built from a different commit than the tag. +# --- Trigger test-pypi build against the RC branch --- -gh workflow run build_wheels.yml --repo "$REPO" --ref "v${NEW_VERSION}" -f upload_to_pypi=true -f test_pypi=true +gh workflow run build_wheels.yml --repo "$REPO" --ref "$RC_BRANCH" -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 "Release candidate v${NEW_VERSION} started (phase 1 of 2):" +echo " - Local: release commit on ${CURRENT_BRANCH}, NOT pushed" +echo " - Remote: branch '${RC_BRANCH}' has the release commit" +echo " - Workflow: https://github.com/${REPO}/actions?query=branch%3A${RC_BRANCH}" 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} --ref v${NEW_VERSION} -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)" +echo "Re-run this script after the workflow finishes to finalize:" +echo " - success -> tag v${NEW_VERSION}, push ${CURRENT_BRANCH}, delete ${RC_BRANCH}" +echo " - failure -> prompt to roll back" From a0698bd7b9bfa16a89a2025bece6ba1ca9e9c5ce Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 22 Apr 2026 17:22:54 +0100 Subject: [PATCH 12/15] Switch release.sh phases to explicit --finalize/--rollback flags --- release.sh | 83 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/release.sh b/release.sh index 5739507b7..c7b7a508d 100755 --- a/release.sh +++ b/release.sh @@ -7,11 +7,24 @@ CHANGELOG="CHANGELOG.md" REPO="scipopt/PySCIPOpt" DRY_RUN=false +ACTION=start 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 ;; + --finalize) ACTION=finalize ;; + --rollback) ACTION=rollback ;; + -h|--help) + cat < Build version (latest and stable)" } -# --- Detect pending release candidate --- -# If HEAD is a local release commit and its RC branch exists on origin, we are in -# the second phase (finalize). Otherwise we are starting a new release. +# Abandon a release candidate: delete RC branch, reset local release commit. +rollback_release() { + local version="$1" + local rc_branch="release-candidate-v${version}" -HEAD_MSG=$(git log -1 --format=%s) -if [[ "$HEAD_MSG" =~ ^release\ v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - PENDING_VERSION="${BASH_REMATCH[1]}" - RC_BRANCH_CHECK="release-candidate-v${PENDING_VERSION}" - if git ls-remote --heads --exit-code "$PUSH_REMOTE" "$RC_BRANCH_CHECK" &>/dev/null; then - finalize_release "$PENDING_VERSION" + echo "Rolling back release candidate v${version}..." + if [[ "$DRY_RUN" == true ]]; then + echo "DRY RUN: would have run:" + echo " git push ${PUSH_REMOTE} --delete ${rc_branch}" + echo " git reset --hard HEAD~1" exit 0 + fi + + if git ls-remote --heads --exit-code "$PUSH_REMOTE" "$rc_branch" &>/dev/null; then + git push "$PUSH_REMOTE" --delete "$rc_branch" else - echo "Error: HEAD is 'release v${PENDING_VERSION}' but no RC branch '${RC_BRANCH_CHECK}' on ${PUSH_REMOTE}." - echo "A previous run may have been interrupted. Recover manually:" - echo " a) Push the RC branch and re-run:" - echo " git push ${PUSH_REMOTE} HEAD:refs/heads/${RC_BRANCH_CHECK}" - echo " b) Or undo the local commit to start over:" - echo " git reset --hard HEAD~1" + echo "(note: ${rc_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 release candidate is pending." + echo "Use:" + echo " ./release.sh --finalize # promote (requires the RC workflow to have succeeded)" + echo " ./release.sh --rollback # abandon (delete RC branch, reset local commit)" + exit 1 fi # --- Read current version --- From 3d190ee0dcb0cf0b2b2b7d018d92a8eac9ae37b8 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 22 Apr 2026 17:23:36 +0100 Subject: [PATCH 13/15] Document two-phase release flow in RELEASE.md --- RELEASE.md | 48 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index af298801e..73a39f1e3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -15,16 +15,46 @@ On the PR: ## Releasing PySCIPOpt -Run `./release.sh` from the `master` branch (use `--dry-run` first to preview without side effects). 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 +Releases run in two phases from `master`, driven by `./release.sh`. The tag and master push only happen in phase 2, so a failed RC 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 a release candidate + +```bash +./release.sh +``` + +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 — you do not wait. + +### Manual verification + +Once the RC workflow finishes (~15–30 min), 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 RC workflow succeeded, then tags `vX.Y.Z`, pushes master, and deletes the RC branch. + +If the smoke test **fails** (or you change your mind): + +```bash +./release.sh --rollback +``` + +Deletes the RC branch and resets the local release commit. test-pypi keeps the uploaded version string, so the next attempt must use a different bump. + +### After finalize -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 --ref vX.Y.Z -f upload_to_pypi=true -f test_pypi=false From e3c2136d5bb8af40d03f6350e3aa2b11e1ac9f69 Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 22 Apr 2026 17:31:23 +0100 Subject: [PATCH 14/15] Rename staging branch, drop time estimates, add --version override --- RELEASE.md | 18 +++++--- release.sh | 122 +++++++++++++++++++++++++++++------------------------ 2 files changed, 79 insertions(+), 61 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 73a39f1e3..d1c1a5e66 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -15,21 +15,27 @@ On the PR: ## Releasing PySCIPOpt -Releases run in two phases from `master`, driven by `./release.sh`. The tag and master push only happen in phase 2, so a failed RC leaves no semantic public trace — just a deletable `release-candidate-vX.Y.Z` branch. +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 `staging-vX.Y.Z` branch. Use `--dry-run` with any command to preview without side effects. -### Phase 1 — start a release candidate +### Phase 1 — start ```bash ./release.sh ``` -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 — you do not wait. +Prompts for the version bump (patch/minor/major), updates `_version.py`, `setup.py`, and `CHANGELOG.md`, commits **locally**, pushes the commit to `staging-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 +./release.sh --version=X.Y.Z +``` ### Manual verification -Once the RC workflow finishes (~15–30 min), install from test-pypi and smoke-test: +Once the staging workflow finishes, install from test-pypi and smoke-test: ```bash pip install -i https://test.pypi.org/simple/ PySCIPOpt==X.Y.Z @@ -43,7 +49,7 @@ If the smoke test **passes**: ./release.sh --finalize ``` -Checks the RC workflow succeeded, then tags `vX.Y.Z`, pushes master, and deletes the RC branch. +Checks the staging workflow succeeded, then tags `vX.Y.Z`, pushes master, and deletes the staging branch. If the smoke test **fails** (or you change your mind): @@ -51,7 +57,7 @@ If the smoke test **fails** (or you change your mind): ./release.sh --rollback ``` -Deletes the RC branch and resets the local release commit. test-pypi keeps the uploaded version string, so the next attempt must use a different bump. +Deletes the staging 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 diff --git a/release.sh b/release.sh index c7b7a508d..ec3f1ce71 100755 --- a/release.sh +++ b/release.sh @@ -8,19 +8,25 @@ 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 || echo "[]") run_count=$(echo "$run_data" | jq 'length') if [[ "$run_count" == "0" ]]; then - echo "Error: no workflow run found for branch '${rc_branch}'." + echo "Error: no workflow run found for branch '${staging_branch}'." echo "Either wait a moment and try again, or clean up manually:" - echo " git push ${PUSH_REMOTE} --delete ${rc_branch}" + echo " git push ${PUSH_REMOTE} --delete ${staging_branch}" echo " git reset --hard HEAD~1" exit 1 fi @@ -107,12 +113,12 @@ finalize_release() { echo "URL: ${url}" echo "" if [[ "$DRY_RUN" == true ]]; then - echo "DRY RUN: would prompt to roll back (delete ${rc_branch} + reset local commit)." + echo "DRY RUN: would prompt to roll back (delete ${staging_branch} + reset local commit)." exit 0 fi - read -rp "Roll back (delete ${rc_branch} and reset local release commit)? [Y/n] " confirm + read -rp "Roll back (delete ${staging_branch} and reset local release commit)? [Y/n] " confirm if [[ ! "${confirm:-Y}" =~ ^[Nn] ]]; then - git push "$PUSH_REMOTE" --delete "$rc_branch" || echo "(note: remote RC delete failed; clean up manually)" + git push "$PUSH_REMOTE" --delete "$staging_branch" || echo "(note: remote staging branch delete failed; clean up manually)" git reset --hard HEAD~1 echo "Rolled back. Repo is at pre-release state." fi @@ -125,7 +131,7 @@ finalize_release() { 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 ${rc_branch}" + echo " git push ${PUSH_REMOTE} --delete ${staging_branch}" exit 0 fi @@ -133,7 +139,7 @@ finalize_release() { git tag "v${version}" git push "$PUSH_REMOTE" "$CURRENT_BRANCH" git push "$PUSH_REMOTE" "v${version}" - git push "$PUSH_REMOTE" --delete "$rc_branch" + git push "$PUSH_REMOTE" --delete "$staging_branch" echo "" echo "Done! v${version} is now tagged on ${PUSH_REMOTE}." @@ -148,23 +154,23 @@ finalize_release() { echo " 4. Update readthedocs: Builds -> Build version (latest and stable)" } -# Abandon a release candidate: delete RC branch, reset local release commit. +# Abandon a pending release: delete staging branch, reset local release commit. rollback_release() { local version="$1" - local rc_branch="release-candidate-v${version}" + local staging_branch="staging-v${version}" - echo "Rolling back 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 ${rc_branch}" + echo " git push ${PUSH_REMOTE} --delete ${staging_branch}" echo " git reset --hard HEAD~1" exit 0 fi - if git ls-remote --heads --exit-code "$PUSH_REMOTE" "$rc_branch" &>/dev/null; then - git push "$PUSH_REMOTE" --delete "$rc_branch" + if git ls-remote --heads --exit-code "$PUSH_REMOTE" "$staging_branch" &>/dev/null; then + git push "$PUSH_REMOTE" --delete "$staging_branch" else - echo "(note: ${rc_branch} already absent from ${PUSH_REMOTE})" + echo "(note: ${staging_branch} already absent from ${PUSH_REMOTE})" fi git reset --hard HEAD~1 echo "Rolled back. Repo is at pre-release state." @@ -199,10 +205,10 @@ 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 release candidate is pending." + echo "Error: HEAD is already 'release v${BASH_REMATCH[1]}' — a pending release exists." echo "Use:" - echo " ./release.sh --finalize # promote (requires the RC workflow to have succeeded)" - echo " ./release.sh --rollback # abandon (delete RC branch, reset local commit)" + echo " ./release.sh --finalize # promote (requires the staging workflow to have succeeded)" + echo " ./release.sh --rollback # abandon (delete staging branch, reset local commit)" exit 1 fi @@ -216,22 +222,28 @@ PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) echo "Current version: ${CURRENT_VERSION}" -# --- Prompt for bump type --- +# --- Determine new version (prompt, or use --version=X.Y.Z override) --- -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 +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 + 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 --- @@ -262,7 +274,7 @@ if [[ "$UNRELEASED_BULLETS" == "0" ]]; then fi TODAY=$(date +%Y.%m.%d) -RC_BRANCH="release-candidate-v${NEW_VERSION}" +STAGING_BRANCH="staging-v${NEW_VERSION}" echo "" if [[ "$DRY_RUN" == true ]]; then echo "DRY RUN: This script would:" @@ -271,13 +283,13 @@ else 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 '${RC_BRANCH}' on ${PUSH_REMOTE}" +echo " 3. Commit locally, push commit to branch '${STAGING_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 RC branch" -echo " - failure -> prompt to roll back (delete RC branch + reset local commit)" +echo " - success -> tag v${NEW_VERSION}, push master, delete staging branch" +echo " - failure -> prompt to roll back (delete staging branch + reset local commit)" echo "" if [[ "$DRY_RUN" == false ]]; then read -rp "Proceed? [Y/n] " confirm @@ -347,27 +359,27 @@ if [[ "$DRY_RUN" == true ]]; then echo "" echo "DRY RUN: would have run:" echo " git commit -m 'release v${NEW_VERSION}'" - echo " git push ${PUSH_REMOTE} HEAD:refs/heads/${RC_BRANCH}" - echo " gh workflow run build_wheels.yml --repo ${REPO} --ref ${RC_BRANCH} -f upload_to_pypi=true -f test_pypi=true" + echo " git push ${PUSH_REMOTE} HEAD:refs/heads/${STAGING_BRANCH}" + echo " gh workflow run build_wheels.yml --repo ${REPO} --ref ${STAGING_BRANCH} -f upload_to_pypi=true -f test_pypi=true" exit 0 fi -# --- Commit locally and push RC branch (no tag, no master push yet) --- +# --- Commit locally and push staging 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/${RC_BRANCH}" +git push "$PUSH_REMOTE" "HEAD:refs/heads/${STAGING_BRANCH}" -# --- Trigger test-pypi build against the RC branch --- +# --- Trigger test-pypi build against the staging branch --- -gh workflow run build_wheels.yml --repo "$REPO" --ref "$RC_BRANCH" -f upload_to_pypi=true -f test_pypi=true +gh workflow run build_wheels.yml --repo "$REPO" --ref "$STAGING_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 '${RC_BRANCH}' has the release commit" -echo " - Workflow: https://github.com/${REPO}/actions?query=branch%3A${RC_BRANCH}" +echo " - Remote: branch '${STAGING_BRANCH}' has the release commit" +echo " - Workflow: https://github.com/${REPO}/actions?query=branch%3A${STAGING_BRANCH}" echo "" echo "Re-run this script after the workflow finishes to finalize:" -echo " - success -> tag v${NEW_VERSION}, push ${CURRENT_BRANCH}, delete ${RC_BRANCH}" +echo " - success -> tag v${NEW_VERSION}, push ${CURRENT_BRANCH}, delete ${STAGING_BRANCH}" echo " - failure -> prompt to roll back" From 6f594394f3dc87930e9c6c748654f920348310bd Mon Sep 17 00:00:00 2001 From: Joao-Dionisio Date: Wed, 22 Apr 2026 17:33:08 +0100 Subject: [PATCH 15/15] Use 'release-candidate' instead of 'staging' --- RELEASE.md | 10 ++++---- release.sh | 72 +++++++++++++++++++++++++++--------------------------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index d1c1a5e66..119e6ac38 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -15,7 +15,7 @@ On the PR: ## 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 `staging-vX.Y.Z` branch. +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. @@ -25,7 +25,7 @@ Use `--dry-run` with any command to preview without side effects. ./release.sh ``` -Prompts for the version bump (patch/minor/major), updates `_version.py`, `setup.py`, and `CHANGELOG.md`, commits **locally**, pushes the commit to `staging-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. +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): @@ -35,7 +35,7 @@ To skip the bump prompt (e.g., when test-pypi has already burnt the default next ### Manual verification -Once the staging workflow finishes, install from test-pypi and smoke-test: +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 @@ -49,7 +49,7 @@ If the smoke test **passes**: ./release.sh --finalize ``` -Checks the staging workflow succeeded, then tags `vX.Y.Z`, pushes master, and deletes the staging branch. +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): @@ -57,7 +57,7 @@ If the smoke test **fails** (or you change your mind): ./release.sh --rollback ``` -Deletes the staging 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. +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 diff --git a/release.sh b/release.sh index ec3f1ce71..44c8ce9eb 100755 --- a/release.sh +++ b/release.sh @@ -19,13 +19,13 @@ for arg in "$@"; do cat </dev/null || echo "[]") run_count=$(echo "$run_data" | jq 'length') if [[ "$run_count" == "0" ]]; then - echo "Error: no workflow run found for branch '${staging_branch}'." + 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 ${staging_branch}" + echo " git push ${PUSH_REMOTE} --delete ${candidate_branch}" echo " git reset --hard HEAD~1" exit 1 fi @@ -113,12 +113,12 @@ finalize_release() { echo "URL: ${url}" echo "" if [[ "$DRY_RUN" == true ]]; then - echo "DRY RUN: would prompt to roll back (delete ${staging_branch} + reset local commit)." + echo "DRY RUN: would prompt to roll back (delete ${candidate_branch} + reset local commit)." exit 0 fi - read -rp "Roll back (delete ${staging_branch} and reset local release commit)? [Y/n] " confirm + 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 "$staging_branch" || echo "(note: remote staging branch delete failed; clean up manually)" + 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 @@ -131,7 +131,7 @@ finalize_release() { 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 ${staging_branch}" + echo " git push ${PUSH_REMOTE} --delete ${candidate_branch}" exit 0 fi @@ -139,7 +139,7 @@ finalize_release() { git tag "v${version}" git push "$PUSH_REMOTE" "$CURRENT_BRANCH" git push "$PUSH_REMOTE" "v${version}" - git push "$PUSH_REMOTE" --delete "$staging_branch" + git push "$PUSH_REMOTE" --delete "$candidate_branch" echo "" echo "Done! v${version} is now tagged on ${PUSH_REMOTE}." @@ -154,23 +154,23 @@ finalize_release() { echo " 4. Update readthedocs: Builds -> Build version (latest and stable)" } -# Abandon a pending release: delete staging branch, reset local release commit. +# Abandon a pending release: delete release-candidate branch, reset local release commit. rollback_release() { local version="$1" - local staging_branch="staging-v${version}" + 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 ${staging_branch}" + 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" "$staging_branch" &>/dev/null; then - git push "$PUSH_REMOTE" --delete "$staging_branch" + if git ls-remote --heads --exit-code "$PUSH_REMOTE" "$candidate_branch" &>/dev/null; then + git push "$PUSH_REMOTE" --delete "$candidate_branch" else - echo "(note: ${staging_branch} already absent from ${PUSH_REMOTE})" + echo "(note: ${candidate_branch} already absent from ${PUSH_REMOTE})" fi git reset --hard HEAD~1 echo "Rolled back. Repo is at pre-release state." @@ -207,8 +207,8 @@ 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 staging workflow to have succeeded)" - echo " ./release.sh --rollback # abandon (delete staging branch, reset local commit)" + 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 @@ -274,7 +274,7 @@ if [[ "$UNRELEASED_BULLETS" == "0" ]]; then fi TODAY=$(date +%Y.%m.%d) -STAGING_BRANCH="staging-v${NEW_VERSION}" +CANDIDATE_BRANCH="release-candidate-v${NEW_VERSION}" echo "" if [[ "$DRY_RUN" == true ]]; then echo "DRY RUN: This script would:" @@ -283,13 +283,13 @@ else 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 '${STAGING_BRANCH}' on ${PUSH_REMOTE}" +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 staging branch" -echo " - failure -> prompt to roll back (delete staging branch + reset local commit)" +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 @@ -359,27 +359,27 @@ if [[ "$DRY_RUN" == true ]]; then echo "" echo "DRY RUN: would have run:" echo " git commit -m 'release v${NEW_VERSION}'" - echo " git push ${PUSH_REMOTE} HEAD:refs/heads/${STAGING_BRANCH}" - echo " gh workflow run build_wheels.yml --repo ${REPO} --ref ${STAGING_BRANCH} -f upload_to_pypi=true -f test_pypi=true" + 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 staging branch (no tag, no master push yet) --- +# --- 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/${STAGING_BRANCH}" +git push "$PUSH_REMOTE" "HEAD:refs/heads/${CANDIDATE_BRANCH}" -# --- Trigger test-pypi build against the staging branch --- +# --- Trigger test-pypi build against the release-candidate branch --- -gh workflow run build_wheels.yml --repo "$REPO" --ref "$STAGING_BRANCH" -f upload_to_pypi=true -f test_pypi=true +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 '${STAGING_BRANCH}' has the release commit" -echo " - Workflow: https://github.com/${REPO}/actions?query=branch%3A${STAGING_BRANCH}" +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 ${STAGING_BRANCH}" +echo " - success -> tag v${NEW_VERSION}, push ${CURRENT_BRANCH}, delete ${CANDIDATE_BRANCH}" echo " - failure -> prompt to roll back"