diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..6c42e390d --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,104 @@ +# 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 -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 +``` + +Confirm each package at its prompt. Upload order matters and the script +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). + +## 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".) diff --git a/scripts/publish-wheels.sh b/scripts/publish-wheels.sh index aa6483811..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' @@ -13,6 +19,9 @@ NC='\033[0m' # No Color ARTIFACT_FILE="pecos-distribution.zip" DRY_RUN=false PACKAGE="" +ASSUME_YES=false + +ALL_PACKAGES=(pecos-rslib pecos-rslib-llvm quantum-pecos) # Parse command line arguments while [[ $# -gt 0 ]]; do @@ -29,13 +38,18 @@ while [[ $# -gt 0 ]]; do DRY_RUN=true shift ;; + -y|--yes) + ASSUME_YES=true + shift + ;; -h|--help) 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)" echo " -h, --help Show this help message" echo "" echo "Examples:" @@ -51,13 +65,20 @@ 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 +# 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" @@ -72,84 +93,165 @@ 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. + 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 -echo -e "${GREEN}Extracting distribution bundle...${NC}" -unzip -q "$ARTIFACT_FILE" -d "$TEMP_DIR" +# Resolve a package's distribution directory (with or without dist/ prefix). +package_dir_for() { + local package_name=$1 + local dir="$DIST_DIR/$package_name" + [ -d "$dir" ] || dir="$DIST_DIR/dist/$package_name" + printf '%s\n' "$dir" +} -# Function to publish a package -publish_package() { +# 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 - # Try both possible locations: with and without dist/ prefix - local package_dir="$TEMP_DIR/$package_name" - if [ ! -d "$package_dir" ]; then - package_dir="$TEMP_DIR/dist/$package_name" + 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 - - if [ ! -d "$package_dir" ]; then - echo -e "${YELLOW}Warning: $package_name directory not found in distribution${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 - - 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 + 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 + # --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}" - if $TWINE_CMD check "$package_dir"/* 2>&1 | grep -v "license-file"; then + local check_output + if check_output=$($TWINE_CMD check --strict "$package_dir"/* 2>&1); then 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 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}" - read -p "Are you sure you want to upload $package_name to PyPI? (y/N) " -n 1 -r - echo - 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}"