From e25638c60e716b6c98a75c0c1d5314ac5f5456a8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 11 Jul 2026 15:43:43 -0600 Subject: [PATCH 1/4] Make publish-wheels.sh fail loudly on twine errors and handle non-interactive confirmation --- scripts/publish-wheels.sh | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/scripts/publish-wheels.sh b/scripts/publish-wheels.sh index aa6483811..62757ef48 100755 --- a/scripts/publish-wheels.sh +++ b/scripts/publish-wheels.sh @@ -13,6 +13,7 @@ NC='\033[0m' # No Color ARTIFACT_FILE="pecos-distribution.zip" DRY_RUN=false PACKAGE="" +ASSUME_YES=false # Parse command line arguments while [[ $# -gt 0 ]]; do @@ -29,6 +30,10 @@ while [[ $# -gt 0 ]]; do DRY_RUN=true shift ;; + -y|--yes) + ASSUME_YES=true + shift + ;; -h|--help) echo "Usage: $0 [OPTIONS]" echo "" @@ -36,6 +41,7 @@ while [[ $# -gt 0 ]]; do echo " -f, --file FILE Path to the GitHub Actions artifact zip (default: pecos-distribution.zip)" echo " -p, --package PKG Publish only specific package (pecos-rslib, pecos-rslib-llvm, or quantum-pecos)" echo " --dry-run Show what would be uploaded without actually uploading" + echo " -y, --yes Skip per-package confirmation prompts (for non-interactive use)" echo " -h, --help Show this help message" echo "" echo "Examples:" @@ -103,19 +109,19 @@ publish_package() { echo "Found $file_count distribution file(s):" ls -la "$package_dir" - # Run twine check + # Run twine check. Capture the output and rely on twine's own exit status: + # twine exits nonzero on real errors and zero on warnings (e.g. the benign + # license-file warning maturin wheels carry). Piping to grep here would + # mask a failed or missing twine with grep's exit status. echo -e "\n${GREEN}Running twine check...${NC}" - if $TWINE_CMD check "$package_dir"/* 2>&1 | grep -v "license-file"; then + local check_output + if check_output=$($TWINE_CMD check "$package_dir"/* 2>&1); then + echo "$check_output" | grep -v "license-file" || true echo -e "${GREEN}Distribution checks passed${NC}" else - # Check if there are errors other than license-file - if $TWINE_CMD check "$package_dir"/* 2>&1 | grep -v "license-file" | grep -q "ERROR"; then - echo -e "${RED}Distribution checks failed${NC}" - echo "Run '$TWINE_CMD check $package_dir/*' to see details" - return 1 - else - echo -e "${YELLOW}Only license-file warnings found (safe to ignore for maturin wheels)${NC}" - fi + echo "$check_output" + echo -e "${RED}Distribution checks failed${NC}" + return 1 fi if [ "$DRY_RUN" = true ]; then @@ -123,8 +129,14 @@ publish_package() { ls -1 "$package_dir" else echo -e "\n${GREEN}Uploading to PyPI...${NC}" - read -p "Are you sure you want to upload $package_name to PyPI? (y/N) " -n 1 -r - echo + if [ "$ASSUME_YES" = true ]; then + REPLY="y" + else + # Read a full line (not -n 1): single-character reads mis-consume + # piped input (e.g. `yes |` leaves a newline that answers the NEXT + # prompt as "no", silently skipping a package). + read -p "Are you sure you want to upload $package_name to PyPI? (y/N) " -r + fi if [[ $REPLY =~ ^[Yy]$ ]]; then $TWINE_CMD upload "$package_dir"/* echo -e "${GREEN}Successfully uploaded $package_name!${NC}" From 8f6f38b88066eb427b8dcf03f95c2d76f951e6eb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 11 Jul 2026 15:47:37 -0600 Subject: [PATCH 2/4] Document the manual release sequence in RELEASING.md --- RELEASING.md | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 RELEASING.md diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..f519f25f8 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,99 @@ +# Releasing PECOS (Python packages) + +The final PyPI push is deliberately **manual**: artifact building and testing +are automated, but a human runs the upload and confirms each package. This +sequence was last exercised for `0.9.0.dev0`. + +## 1. Version bump (its own PR) + +The version is a literal in many coordinated places. Bump them all: + +- `version =` in every workspace `pyproject.toml` (root meta-package, + `python/pecos-rslib{,-llvm,-cuda,-exp}`, `python/quantum-pecos`, and the + `python/selene-plugins/*` packages) +- Exact-version internal pins: the root's `quantum-pecos[cuda12/13]==...` + entries and quantum-pecos's `pecos-rslib==...` / `pecos-rslib-llvm==...` +- Regenerate both lockfiles: `uv lock` at the root and in `exp/zluppy/` + (verify the diffs are version-lines-only) +- `docs/user-guide/cli.md` example output + +Verify: `git grep ` returns nothing; `uv lock --check` passes; +`just python-ci-sync-test` then `python -c "import pecos; print(pecos.__version__)"` +reports the new version. + +Versioning convention: a user-visible default-behavior change gets a minor +bump (e.g. `0.8.x -> 0.9.0.dev0`); additive-only work increments the dev/patch +number. + +## 2. Merge and wait for post-merge green + +Merge the bump PR to `dev`, then wait for **all** post-merge workflows on the +merge commit to pass -- including the long ones (`python-core` is the full +suite and only runs post-merge; `Python Artifacts` is the full wheel matrix, +i.e. the release rehearsal). Optionally run `just check-all` locally on the +merge commit as a belt-and-braces gate. + +## 3. Tag + +``` +git tag py- +git push origin py- +``` + +The `py-*` tag triggers `python-release.yml` in full-release mode: all +platform wheels (pecos-rslib, pecos-rslib-llvm), quantum-pecos wheel + sdist, +abi3 wheel tests across Python versions, and a `collect_artifacts` bundle +pinned to the tagged commit. Wait for it to go green. Publish from the **tag +run's** bundle, not a branch run -- provenance stays tied to the immutable +ref. + +## 4. Download the bundle and dry-run + +``` +gh run download -n pecos-distribution +# gh auto-extracts; the publish script wants the zip: +zip -qr pecos-distribution.zip pecos-rslib pecos-rslib-llvm quantum-pecos +./scripts/publish-wheels.sh --dry-run -f pecos-distribution.zip +``` + +The dry run must show a real `twine check ... PASSED` per file (twine must be +installed, e.g. `uv tool install twine`). + +## 5. Publish (the manual step) + +``` +./scripts/publish-wheels.sh -f pecos-distribution.zip +``` + +Confirm each package at its prompt. Upload order matters and the script +handles it: `pecos-rslib` -> `pecos-rslib-llvm` -> `quantum-pecos` +(quantum-pecos pins both at exact versions, so dependencies go first). +Credentials come from `~/.pypirc`; a **new** package's first upload needs an +account-scoped token (project-scoped tokens cannot create projects). + +## 6. GitHub release + +Create a release on the tag (marked pre-release for dev versions), attach the +bundle's files, and include: + +- the headline change and any escape hatch / migration note +- an install snippet -- uv users need `--prerelease=allow` for dev-version + transitive pins + +``` +gh release create py- --prerelease --title "py-" \ + --notes-file --generate-notes +``` + +## 7. Verify from PyPI + +Prove the published set resolves and runs, from a clean environment: + +``` +uv venv /tmp/verify && uv pip install --python /tmp/verify \ + --no-cache --prerelease=allow quantum-pecos== +/tmp/verify/bin/python -c "import pecos; print(pecos.__version__)" +``` + +(`--no-cache` matters: a resolver that looked while an upload was in flight +may have cached "not found".) From 81d2a51332bdc73d473faeb3b30f17941e430a04 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 11 Jul 2026 15:55:32 -0600 Subject: [PATCH 3/4] Accept an extracted artifact directory in publish-wheels.sh --- RELEASING.md | 10 +++++----- scripts/publish-wheels.sh | 25 +++++++++++++++++-------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index f519f25f8..9281b4dea 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -50,19 +50,19 @@ ref. ## 4. Download the bundle and dry-run ``` -gh run download -n pecos-distribution -# gh auto-extracts; the publish script wants the zip: -zip -qr pecos-distribution.zip pecos-rslib pecos-rslib-llvm quantum-pecos -./scripts/publish-wheels.sh --dry-run -f pecos-distribution.zip +gh run download -n pecos-distribution -D pecos-distribution +./scripts/publish-wheels.sh --dry-run -f pecos-distribution ``` +(`-f` accepts the extracted directory `gh` produces, or the original zip.) + The dry run must show a real `twine check ... PASSED` per file (twine must be installed, e.g. `uv tool install twine`). ## 5. Publish (the manual step) ``` -./scripts/publish-wheels.sh -f pecos-distribution.zip +./scripts/publish-wheels.sh -f pecos-distribution ``` Confirm each package at its prompt. Upload order matters and the script diff --git a/scripts/publish-wheels.sh b/scripts/publish-wheels.sh index 62757ef48..5cce8850e 100755 --- a/scripts/publish-wheels.sh +++ b/scripts/publish-wheels.sh @@ -38,7 +38,7 @@ while [[ $# -gt 0 ]]; do echo "Usage: $0 [OPTIONS]" echo "" echo "Options:" - echo " -f, --file FILE Path to the GitHub Actions artifact zip (default: pecos-distribution.zip)" + echo " -f, --file FILE Artifact zip OR extracted artifact directory (default: pecos-distribution.zip)" echo " -p, --package PKG Publish only specific package (pecos-rslib, pecos-rslib-llvm, or quantum-pecos)" echo " --dry-run Show what would be uploaded without actually uploading" echo " -y, --yes Skip per-package confirmation prompts (for non-interactive use)" @@ -57,9 +57,10 @@ while [[ $# -gt 0 ]]; do esac done -# Check if artifact file exists -if [ ! -f "$ARTIFACT_FILE" ]; then - echo -e "${RED}Error: Artifact file '$ARTIFACT_FILE' not found!${NC}" +# Check if the artifact exists: either the zip, or an already-extracted +# directory (`gh run download` auto-extracts artifacts). +if [ ! -f "$ARTIFACT_FILE" ] && [ ! -d "$ARTIFACT_FILE" ]; then + echo -e "${RED}Error: Artifact '$ARTIFACT_FILE' not found!${NC}" echo "Please download the 'pecos-distribution' artifact from GitHub Actions." exit 1 fi @@ -82,16 +83,24 @@ fi TEMP_DIR=$(mktemp -d) trap "rm -rf $TEMP_DIR" EXIT -echo -e "${GREEN}Extracting distribution bundle...${NC}" -unzip -q "$ARTIFACT_FILE" -d "$TEMP_DIR" +if [ -d "$ARTIFACT_FILE" ]; then + # Already-extracted artifact directory: read it in place (copy nothing, + # delete nothing -- TEMP_DIR cleanup must not touch the user's files). + echo -e "${GREEN}Using extracted distribution directory...${NC}" + DIST_DIR="$ARTIFACT_FILE" +else + echo -e "${GREEN}Extracting distribution bundle...${NC}" + unzip -q "$ARTIFACT_FILE" -d "$TEMP_DIR" + DIST_DIR="$TEMP_DIR" +fi # Function to publish a package publish_package() { local package_name=$1 # Try both possible locations: with and without dist/ prefix - local package_dir="$TEMP_DIR/$package_name" + local package_dir="$DIST_DIR/$package_name" if [ ! -d "$package_dir" ]; then - package_dir="$TEMP_DIR/dist/$package_name" + package_dir="$DIST_DIR/dist/$package_name" fi if [ ! -d "$package_dir" ]; then From f38926c403e535d59846848546102d6ef65eb95c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 11 Jul 2026 17:44:48 -0600 Subject: [PATCH 4/4] Preflight the artifact set and fail closed on any publish prerequisite (review findings) --- RELEASING.md | 9 +- scripts/publish-wheels.sh | 185 +++++++++++++++++++++++++++----------- 2 files changed, 140 insertions(+), 54 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 9281b4dea..6c42e390d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -66,8 +66,13 @@ installed, e.g. `uv tool install twine`). ``` Confirm each package at its prompt. Upload order matters and the script -handles it: `pecos-rslib` -> `pecos-rslib-llvm` -> `quantum-pecos` -(quantum-pecos pins both at exact versions, so dependencies go first). +enforces it: all packages are preflighted (complete set, consistent version, +only expected files) before anything uploads, dependencies go first +(`pecos-rslib` -> `pecos-rslib-llvm` -> `quantum-pecos`, which pins both at +exact versions), and any failure or declined prompt aborts the remaining +uploads rather than continuing. Publishing `quantum-pecos` alone (`-p`) is +blocked unless its pinned dependencies already exist on PyPI. Checks run +`twine check --strict`. Credentials come from `~/.pypirc`; a **new** package's first upload needs an account-scoped token (project-scoped tokens cannot create projects). diff --git a/scripts/publish-wheels.sh b/scripts/publish-wheels.sh index 5cce8850e..8dc29b2f7 100755 --- a/scripts/publish-wheels.sh +++ b/scripts/publish-wheels.sh @@ -1,7 +1,13 @@ #!/bin/bash # Script to help publish PECOS wheels to PyPI from GitHub Actions artifacts +# +# Publish order matters: quantum-pecos pins pecos-rslib and pecos-rslib-llvm at +# exact versions, so the dependencies must exist on PyPI before quantum-pecos. +# All-packages mode preflights the complete artifact set and aborts on any +# missing package or declined prompt rather than continuing (a partial publish +# leaves quantum-pecos with unresolvable pins -- this has happened). -set -e +set -euo pipefail # Colors for output RED='\033[0;31m' @@ -15,6 +21,8 @@ DRY_RUN=false PACKAGE="" ASSUME_YES=false +ALL_PACKAGES=(pecos-rslib pecos-rslib-llvm quantum-pecos) + # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in @@ -65,6 +73,12 @@ if [ ! -f "$ARTIFACT_FILE" ] && [ ! -d "$ARTIFACT_FILE" ]; then exit 1 fi +# Prompts need a terminal; refuse to guess in non-interactive contexts. +if [ "$ASSUME_YES" = false ] && [ "$DRY_RUN" = false ] && [ ! -t 0 ]; then + echo -e "${RED}Error: stdin is not a terminal. Use --yes for non-interactive publishing.${NC}" + exit 1 +fi + # Check if uv is installed (preferred) or twine directly if command -v uv &> /dev/null; then TWINE_CMD="uv run twine" @@ -79,53 +93,85 @@ else exit 1 fi -# Create temporary directory -TEMP_DIR=$(mktemp -d) -trap "rm -rf $TEMP_DIR" EXIT - if [ -d "$ARTIFACT_FILE" ]; then - # Already-extracted artifact directory: read it in place (copy nothing, - # delete nothing -- TEMP_DIR cleanup must not touch the user's files). + # Already-extracted artifact directory: read it in place. echo -e "${GREEN}Using extracted distribution directory...${NC}" DIST_DIR="$ARTIFACT_FILE" else + TEMP_DIR=$(mktemp -d) + trap 'rm -rf -- "${TEMP_DIR:?}"' EXIT echo -e "${GREEN}Extracting distribution bundle...${NC}" unzip -q "$ARTIFACT_FILE" -d "$TEMP_DIR" DIST_DIR="$TEMP_DIR" fi -# Function to publish a package -publish_package() { +# Resolve a package's distribution directory (with or without dist/ prefix). +package_dir_for() { local package_name=$1 - # Try both possible locations: with and without dist/ prefix - local package_dir="$DIST_DIR/$package_name" - if [ ! -d "$package_dir" ]; then - package_dir="$DIST_DIR/dist/$package_name" - fi + local dir="$DIST_DIR/$package_name" + [ -d "$dir" ] || dir="$DIST_DIR/dist/$package_name" + printf '%s\n' "$dir" +} - if [ ! -d "$package_dir" ]; then - echo -e "${YELLOW}Warning: $package_name directory not found in distribution${NC}" - return +# Preflight one package: directory exists, contains only expected distribution +# files for that project, and report the single version they carry. +# Prints the version on stdout; fails the script otherwise. +preflight_package() { + local package_name=$1 + local dir + dir=$(package_dir_for "$package_name") + if [ ! -d "$dir" ]; then + echo -e "${RED}Error: $package_name directory not found in distribution${NC}" >&2 + return 1 fi - - local file_count=$(ls -1 "$package_dir" | wc -l) - if [ "$file_count" -eq 0 ]; then - echo -e "${YELLOW}Warning: No files found in $package_name directory${NC}" - return + local prefix="${package_name//-/_}" + local versions=() + local f base ver + shopt -s nullglob + local files=("$dir"/*) + shopt -u nullglob + if [ "${#files[@]}" -eq 0 ]; then + echo -e "${RED}Error: no files found in $dir${NC}" >&2 + return 1 fi + for f in "${files[@]}"; do + base=$(basename "$f") + case "$base" in + "$prefix"-*.whl|"$prefix"-*.tar.gz) ;; + *) + echo -e "${RED}Error: unexpected file in $dir: $base (expected only $prefix-*.whl / $prefix-*.tar.gz)${NC}" >&2 + return 1 + ;; + esac + ver=${base#"$prefix"-} + ver=${ver%%-*} + ver=${ver%.tar.gz} + versions+=("$ver") + done + local unique + unique=$(printf '%s\n' "${versions[@]}" | sort -u) + if [ "$(printf '%s\n' "$unique" | wc -l)" -ne 1 ]; then + echo -e "${RED}Error: mixed versions in $dir: ${unique//$'\n'/ }${NC}" >&2 + return 1 + fi + printf '%s\n' "$unique" +} + +# Publish a package (assumed preflighted). Fails the script on any error or on +# a declined prompt -- callers rely on this to abort dependent uploads. +publish_package() { + local package_name=$1 + local package_dir + package_dir=$(package_dir_for "$package_name") echo -e "\n${GREEN}=== Publishing $package_name ===${NC}" - echo "Found $file_count distribution file(s):" ls -la "$package_dir" - # Run twine check. Capture the output and rely on twine's own exit status: - # twine exits nonzero on real errors and zero on warnings (e.g. the benign - # license-file warning maturin wheels carry). Piping to grep here would - # mask a failed or missing twine with grep's exit status. + # --strict: fail on warnings too. Current maturin/hatchling artifacts pass + # strict checks cleanly, so any warning is a real signal. echo -e "\n${GREEN}Running twine check...${NC}" local check_output - if check_output=$($TWINE_CMD check "$package_dir"/* 2>&1); then - echo "$check_output" | grep -v "license-file" || true + if check_output=$($TWINE_CMD check --strict "$package_dir"/* 2>&1); then echo -e "${GREEN}Distribution checks passed${NC}" else echo "$check_output" @@ -136,41 +182,76 @@ publish_package() { if [ "$DRY_RUN" = true ]; then echo -e "\n${YELLOW}DRY RUN: Would upload the following files:${NC}" ls -1 "$package_dir" + return 0 + fi + + echo -e "\n${GREEN}Uploading to PyPI...${NC}" + if [ "$ASSUME_YES" = true ]; then + REPLY="y" else - echo -e "\n${GREEN}Uploading to PyPI...${NC}" - if [ "$ASSUME_YES" = true ]; then - REPLY="y" - else - # Read a full line (not -n 1): single-character reads mis-consume - # piped input (e.g. `yes |` leaves a newline that answers the NEXT - # prompt as "no", silently skipping a package). - read -p "Are you sure you want to upload $package_name to PyPI? (y/N) " -r - fi - if [[ $REPLY =~ ^[Yy]$ ]]; then - $TWINE_CMD upload "$package_dir"/* - echo -e "${GREEN}Successfully uploaded $package_name!${NC}" - else - echo -e "${YELLOW}Skipped uploading $package_name${NC}" + # Full-line read: single-character reads mis-consume piped input. A + # read failure (EOF) aborts loudly rather than guessing. + if ! read -p "Are you sure you want to upload $package_name to PyPI? (y/N) " -r; then + echo -e "${RED}Error: could not read confirmation (EOF); aborting.${NC}" + return 1 fi fi + if [[ $REPLY =~ ^[Yy]$ ]]; then + $TWINE_CMD upload "$package_dir"/* + echo -e "${GREEN}Successfully uploaded $package_name!${NC}" + else + echo -e "${RED}Declined uploading $package_name; aborting (later packages depend on it).${NC}" + return 1 + fi +} + +# Verify an exact pin exists on PyPI (used to guard publishing quantum-pecos +# on its own: its exact pecos-rslib/-llvm pins must already be resolvable). +pin_exists_on_pypi() { + local name=$1 version=$2 + curl -fsSL -o /dev/null "https://pypi.org/pypi/$name/$version/json" } # Main execution if [ -n "$PACKAGE" ]; then - # Publish specific package - if [[ "$PACKAGE" != "pecos-rslib" && "$PACKAGE" != "pecos-rslib-llvm" && "$PACKAGE" != "quantum-pecos" ]]; then + if ! printf '%s\n' "${ALL_PACKAGES[@]}" | grep -qx "$PACKAGE"; then echo -e "${RED}Error: Invalid package name '$PACKAGE'${NC}" - echo "Valid options are: pecos-rslib, pecos-rslib-llvm, quantum-pecos" + echo "Valid options are: ${ALL_PACKAGES[*]}" exit 1 fi + version=$(preflight_package "$PACKAGE") + echo -e "${GREEN}Preflight OK: $PACKAGE $version${NC}" + if [ "$PACKAGE" = "quantum-pecos" ] && [ "$DRY_RUN" = false ]; then + for dep in pecos-rslib pecos-rslib-llvm; do + if ! pin_exists_on_pypi "$dep" "$version"; then + echo -e "${RED}Error: quantum-pecos==$version pins $dep==$version, which is not on PyPI.${NC}" + echo "Publish $dep first (or use all-packages mode)." + exit 1 + fi + done + fi publish_package "$PACKAGE" else - # Publish all packages. quantum-pecos pins pecos-rslib and pecos-rslib-llvm - # at exact versions, so publish the dependencies first. - echo -e "${GREEN}Publishing all PECOS packages${NC}" - publish_package "pecos-rslib" - publish_package "pecos-rslib-llvm" - publish_package "quantum-pecos" + # All-packages mode: preflight EVERYTHING before uploading anything, and + # require one consistent version across the set. + echo -e "${GREEN}Preflighting all PECOS packages${NC}" + versions=() + for pkg in "${ALL_PACKAGES[@]}"; do + v=$(preflight_package "$pkg") + echo -e "${GREEN}Preflight OK: $pkg $v${NC}" + versions+=("$v") + done + unique=$(printf '%s\n' "${versions[@]}" | sort -u) + if [ "$(printf '%s\n' "$unique" | wc -l)" -ne 1 ]; then + echo -e "${RED}Error: packages carry different versions: ${unique//$'\n'/ }${NC}" + exit 1 + fi + echo -e "${GREEN}Publishing all PECOS packages at version $unique${NC}" + # Dependencies first; publish_package aborts the script on failure or + # decline, so quantum-pecos cannot publish without its pinned deps. + for pkg in "${ALL_PACKAGES[@]}"; do + publish_package "$pkg" + done fi echo -e "\n${GREEN}Done!${NC}"