Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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 <old-version>` 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-<version> <merge-sha>
git push origin py-<version>
```

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 <tag-run-id> -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-<version> --prerelease --title "py-<version>" \
--notes-file <notes.md> --generate-notes <dist-files...>
```

## 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==<version>
/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".)
208 changes: 155 additions & 53 deletions scripts/publish-wheels.sh
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand All @@ -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:"
Expand All @@ -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"
Expand All @@ -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}"
Loading