DataDesigner/scripts/publish.sh
Johnny Greco 63c8dcc11d
chore: simplify publish script by removing redundant rebuild step (#268)
- Remove rebuild_with_tag() function that caused double builds
- Add dedicated delete_local_tag() function for TestPyPI cleanup
- Production workflow now builds once: create local tag -> build -> upload -> push tag
- Tag is only pushed after successful upload, so local tag can be deleted if build fails
2026-01-29 11:55:08 -05:00

570 lines
18 KiB
Bash
Executable file

#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Publish script for DataDesigner
# Publishes all three subpackages to PyPI with the same version.
#
# Usage:
# ./scripts/publish.sh 0.3.9rc1 # Full publish
# ./scripts/publish.sh 0.3.9rc1 --dry-run # Dry run (build, check, no upload)
# ./scripts/publish.sh 0.3.9rc1 --test-pypi # Upload to TestPyPI instead of PyPI
set -e
# ==============================================================================
# COLORS AND FORMATTING
# ==============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# ==============================================================================
# CONFIGURATION
# ==============================================================================
PACKAGE_DIRS=(
"packages/data-designer-config"
"packages/data-designer-engine"
"packages/data-designer"
)
PYPIRC_FILE="$HOME/.pypirc"
EXPECTED_PYPI_USERNAME="data-designer-team"
# PyPI repository URLs
PYPI_REPOSITORY="pypi"
TEST_PYPI_REPOSITORY="testpypi"
TEST_PYPI_URL="https://test.pypi.org/simple/"
# ==============================================================================
# HELPER FUNCTIONS
# ==============================================================================
info() {
echo -e "${BLUE}INFO:${NC} $1"
}
success() {
echo -e "${GREEN}SUCCESS:${NC} $1"
}
warn() {
echo -e "${YELLOW}WARNING:${NC} $1"
}
error() {
echo -e "${RED}ERROR:${NC} $1" >&2
}
abort() {
error "$1"
if [[ -n "$2" ]]; then
echo -e " ${YELLOW}$2${NC}" >&2
fi
exit 1
}
header() {
echo ""
echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD}$1${NC}"
echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}"
}
# ==============================================================================
# ARGUMENT PARSING
# ==============================================================================
VERSION=""
DRY_RUN=false
ALLOW_BRANCH=false
FORCE_TAG=false
TEST_PYPI=false
usage() {
local exit_code="${1:-1}"
echo "Usage: $0 <version> [options]"
echo ""
echo "Publish all DataDesigner packages to PyPI with synchronized versions."
echo ""
echo "Arguments:"
echo " version Version to publish (e.g., 0.3.9 or 0.3.9rc1)"
echo ""
echo "Options:"
echo " -h, --help Show this help message and exit"
echo " --dry-run Build packages and run validation (twine check) but don't"
echo " create tags or upload. Good for CI validation."
echo " --test-pypi Upload to TestPyPI (test.pypi.org) instead of production PyPI."
echo " Useful for testing the full upload flow safely."
echo " --allow-branch Allow publishing from non-main branches"
echo " --force-tag Overwrite existing git tag if it exists"
echo ""
echo "Examples:"
echo " $0 0.3.9rc1 # Full publish to PyPI"
echo " $0 0.3.9rc1 --dry-run # Validate only (build + twine check)"
echo " $0 0.3.9rc1 --test-pypi # Upload to TestPyPI"
echo " $0 0.3.9rc1 --test-pypi --allow-branch # Test from feature branch"
echo " $0 0.3.9rc1 --force-tag # Overwrite existing tag"
echo ""
echo "Version format:"
echo " Valid: 0.3.9, 0.3.9rc1, 1.0.0rc2"
echo " Invalid: v0.3.9, 0.3.9-rc1, 0.3.9a1"
exit "$exit_code"
}
parse_args() {
# Check for help flag first
for arg in "$@"; do
if [[ "$arg" == "-h" ]] || [[ "$arg" == "--help" ]]; then
usage 0
fi
done
if [[ $# -lt 1 ]]; then
usage
fi
VERSION="$1"
shift
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN=true
shift
;;
--test-pypi)
TEST_PYPI=true
shift
;;
--allow-branch)
ALLOW_BRANCH=true
shift
;;
--force-tag)
FORCE_TAG=true
shift
;;
*)
error "Unknown argument: $1"
usage
;;
esac
done
}
# ==============================================================================
# VALIDATION FUNCTIONS
# ==============================================================================
validate_version_format() {
# Validate version matches X.Y.Z or X.Y.ZrcN pattern
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(rc[0-9]+)?$ ]]; then
abort "Invalid version format: $VERSION" \
"Version must match pattern X.Y.Z or X.Y.ZrcN (e.g., 0.3.9 or 0.3.9rc1)"
fi
success "Version format is valid: $VERSION"
}
check_main_branch() {
local current_branch
current_branch=$(git branch --show-current)
if [[ "$current_branch" != "main" ]]; then
if [[ "$ALLOW_BRANCH" == true ]]; then
warn "Not on main branch (allowed via --allow-branch): $current_branch"
else
abort "Not on main branch" \
"Current branch: $current_branch. Please checkout main or use --allow-branch"
fi
else
success "On main branch"
fi
}
check_clean_working_directory() {
local status
status=$(git status --porcelain)
if [[ -n "$status" ]]; then
abort "Working directory has uncommitted changes" \
"Please commit or stash your changes before publishing"
fi
success "Working directory is clean"
}
check_tag_does_not_exist() {
if [[ "$DRY_RUN" == true ]]; then
info "Skipping git tag check (dry-run mode)"
return
fi
local tag="v$VERSION"
local existing_tag
existing_tag=$(git tag -l "$tag")
if [[ -n "$existing_tag" ]]; then
if [[ "$FORCE_TAG" == true ]]; then
warn "Git tag already exists: $tag (will be overwritten via --force-tag)"
else
abort "Git tag already exists: $tag" \
"Please choose a different version or delete the existing tag: git tag -d $tag"
fi
else
success "Git tag v$VERSION does not exist"
fi
}
check_pypi_access() {
if [[ "$DRY_RUN" == true ]]; then
info "Skipping PyPI access check (dry-run mode)"
return
fi
if [[ ! -f "$PYPIRC_FILE" ]]; then
abort "PyPI config file not found: $PYPIRC_FILE" \
"Please create ~/.pypirc with your PyPI credentials"
fi
if [[ "$TEST_PYPI" == true ]]; then
# Check for testpypi section in pypirc
if ! grep -q "\[testpypi\]" "$PYPIRC_FILE"; then
abort "TestPyPI section not found in $PYPIRC_FILE" \
"Please add a [testpypi] section to ~/.pypirc"
fi
success "TestPyPI access configured"
else
# Check that the expected username exists in pypirc
if ! grep -q "$EXPECTED_PYPI_USERNAME" "$PYPIRC_FILE"; then
abort "Expected username '$EXPECTED_PYPI_USERNAME' not found in $PYPIRC_FILE" \
"Please add your PyPI credentials with username '$EXPECTED_PYPI_USERNAME'"
fi
success "PyPI access configured (username: $EXPECTED_PYPI_USERNAME)"
fi
}
check_twine_works() {
if ! uv run --with twine python -m twine --version > /dev/null 2>&1; then
abort "Twine is not working" \
"Please ensure uv is installed and working"
fi
local twine_version
twine_version=$(uv run --with twine python -m twine --version 2>/dev/null | head -1)
success "Twine is available: $twine_version"
}
# ==============================================================================
# BUILD AND PUBLISH FUNCTIONS
# ==============================================================================
run_tests() {
header "Running Tests"
info "Executing make test..."
if ! make test; then
abort "Tests failed" \
"Please fix the failing tests before publishing"
fi
success "All tests passed"
}
build_packages() {
header "Building Packages"
info "Cleaning dist directories..."
make clean-dist
info "Building all packages..."
if ! make build; then
abort "Package build failed" \
"Please check the build output for errors"
fi
success "All packages built successfully"
}
check_packages_with_twine() {
header "Validating Packages with Twine"
info "Running twine check on all distribution files..."
local check_failed=false
for pkg_dir in "${PACKAGE_DIRS[@]}"; do
local pkg_name
pkg_name=$(basename "$pkg_dir")
info "Checking $pkg_name..."
if ! uv run --with twine python -m twine check "$pkg_dir/dist/"*; then
error "twine check failed for $pkg_name"
check_failed=true
else
success " $pkg_name passed twine check"
fi
done
if [[ "$check_failed" == true ]]; then
abort "Package validation failed" \
"Please fix the issues reported by twine check"
fi
success "All packages passed twine check"
}
create_git_tag() {
header "Creating Git Tag"
local tag="v$VERSION"
if [[ "$DRY_RUN" == true ]]; then
if [[ "$FORCE_TAG" == true ]]; then
warn "[DRY RUN] Would force create git tag: $tag"
else
warn "[DRY RUN] Would create git tag: $tag"
fi
return
fi
if [[ "$FORCE_TAG" == true ]]; then
info "Force creating tag: $tag"
git tag -f "$tag"
else
info "Creating tag: $tag"
git tag "$tag"
fi
if [[ "$TEST_PYPI" == true ]]; then
success "Created git tag: $tag (local only, will not be pushed)"
else
success "Created git tag: $tag"
fi
}
verify_package_versions() {
# Verify the built packages have the correct version
header "Verifying Package Versions"
info "Checking that built packages match expected version: $VERSION"
for pkg_dir in "${PACKAGE_DIRS[@]}"; do
local pkg_name
pkg_name=$(basename "$pkg_dir")
# Check wheel exists
local wheel_file
wheel_file=$(ls "$pkg_dir/dist/"*.whl 2>/dev/null | head -1)
if [[ -z "$wheel_file" ]]; then
abort "No wheel found in $pkg_dir/dist/" \
"Build may have failed silently"
fi
# Verify version in wheel filename
# Use -E for extended regex (works on both macOS/BSD and Linux/GNU sed)
local wheel_version
wheel_version=$(basename "$wheel_file" | sed -E -n 's/.*-([0-9]+\.[0-9]+\.[0-9]+(rc[0-9]+)?)-.*/\1/p')
if [[ "$wheel_version" != "$VERSION" ]]; then
abort "Version mismatch in $pkg_name wheel" \
"Expected: $VERSION, Found: $wheel_version in $(basename "$wheel_file")"
fi
info " $pkg_name: $wheel_version"
done
success "All package versions verified"
}
upload_to_pypi() {
local target_repo="$PYPI_REPOSITORY"
local target_name="PyPI"
local repo_args=()
if [[ "$TEST_PYPI" == true ]]; then
target_repo="$TEST_PYPI_REPOSITORY"
target_name="TestPyPI"
repo_args=("--repository" "$target_repo")
fi
header "Uploading to $target_name"
if [[ "$DRY_RUN" == true ]]; then
warn "[DRY RUN] Would upload the following packages to $target_name:"
for pkg_dir in "${PACKAGE_DIRS[@]}"; do
echo " From $pkg_dir/dist/:"
ls -1 "$pkg_dir/dist/" 2>/dev/null | sed 's/^/ /'
done
return
fi
for pkg_dir in "${PACKAGE_DIRS[@]}"; do
local pkg_name
pkg_name=$(basename "$pkg_dir")
info "Uploading $pkg_name to $target_name..."
(
cd "$pkg_dir"
if ! uv run --with twine python -m twine upload "${repo_args[@]}" dist/*; then
abort "Failed to upload $pkg_name to $target_name" \
"If some packages uploaded successfully, you may need to:\n 1. Wait for the failed package issue to be resolved\n 2. Re-run with --force-tag: ./scripts/publish.sh $VERSION --force-tag"
fi
)
success "Uploaded $pkg_name to $target_name"
done
success "All packages uploaded to $target_name"
}
delete_local_tag() {
header "Cleaning Up Local Tag"
local tag="v$VERSION"
if [[ "$DRY_RUN" == true ]]; then
warn "[DRY RUN] Would delete local git tag: $tag"
return
fi
info "Deleting local tag (TestPyPI mode - tag not pushed to remote)"
git tag -d "$tag" > /dev/null 2>&1 || true
success "Deleted local git tag: $tag"
}
push_git_tag() {
header "Pushing Git Tag"
local tag="v$VERSION"
if [[ "$DRY_RUN" == true ]]; then
if [[ "$FORCE_TAG" == true ]]; then
warn "[DRY RUN] Would force push git tag to origin: $tag"
else
warn "[DRY RUN] Would push git tag to origin: $tag"
fi
return
fi
if [[ "$FORCE_TAG" == true ]]; then
info "Force pushing tag to origin: $tag"
if ! git push -f origin "$tag"; then
abort "Failed to force push tag to origin" \
"Please push the tag manually: git push -f origin $tag"
fi
else
info "Pushing tag to origin: $tag"
if ! git push origin "$tag"; then
abort "Failed to push tag to origin" \
"Please push the tag manually: git push origin $tag"
fi
fi
success "Pushed git tag: $tag"
}
# ==============================================================================
# MAIN
# ==============================================================================
main() {
parse_args "$@"
header "DataDesigner Publish v$VERSION"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN MODE - No tags will be created or packages uploaded"
info "Will build packages and run twine check for validation"
fi
if [[ "$TEST_PYPI" == true ]]; then
warn "TEST PYPI MODE - Uploading to test.pypi.org instead of pypi.org"
fi
if [[ "$FORCE_TAG" == true ]]; then
warn "FORCE TAG MODE - Existing tag will be overwritten"
fi
# Pre-flight checks
header "Pre-flight Checks"
validate_version_format
check_main_branch
check_clean_working_directory
check_tag_does_not_exist
check_pypi_access
check_twine_works
# Run tests
run_tests
# Different flows for dry-run, TestPyPI, and production
if [[ "$DRY_RUN" == true ]]; then
# Dry run: build without tag (dev versions), validate, done
build_packages
check_packages_with_twine
elif [[ "$TEST_PYPI" == true ]]; then
# TestPyPI: create temporary tag first, build once with correct version
create_git_tag
build_packages
verify_package_versions
check_packages_with_twine
upload_to_pypi
delete_local_tag
else
# Production: create tag first (local only), build once with correct version
# If build fails, the local tag can be deleted - it's only pushed at the end
create_git_tag
build_packages
verify_package_versions
check_packages_with_twine
upload_to_pypi
push_git_tag
fi
# Final summary
header "Publish Complete"
if [[ "$DRY_RUN" == true ]]; then
success "DRY RUN completed successfully"
echo ""
echo "All validation checks passed:"
echo " - Version format validated"
echo " - Packages built successfully"
echo " - Packages passed twine check"
echo ""
info "Next steps:"
echo " - To publish to TestPyPI (recommended first):"
echo " $0 $VERSION --test-pypi"
echo " - To publish to production PyPI:"
echo " $0 $VERSION"
elif [[ "$TEST_PYPI" == true ]]; then
success "Successfully published DataDesigner v$VERSION to TestPyPI"
echo ""
echo "Packages published to TestPyPI:"
for pkg_dir in "${PACKAGE_DIRS[@]}"; do
local pkg_name
pkg_name=$(basename "$pkg_dir")
echo " - $pkg_name"
done
echo ""
echo "View on TestPyPI:"
echo " https://test.pypi.org/project/data-designer-config/$VERSION/"
echo " https://test.pypi.org/project/data-designer-engine/$VERSION/"
echo " https://test.pypi.org/project/data-designer/$VERSION/"
echo ""
echo "Test installation with:"
echo " pip install --index-url $TEST_PYPI_URL data-designer==$VERSION"
echo ""
info "If everything looks good, publish to production PyPI:"
echo " $0 $VERSION"
else
success "Successfully published DataDesigner v$VERSION"
echo ""
echo "Packages published:"
for pkg_dir in "${PACKAGE_DIRS[@]}"; do
local pkg_name
pkg_name=$(basename "$pkg_dir")
echo " - $pkg_name"
done
echo ""
echo "View on PyPI:"
echo " https://pypi.org/project/data-designer-config/$VERSION/"
echo " https://pypi.org/project/data-designer-engine/$VERSION/"
echo " https://pypi.org/project/data-designer/$VERSION/"
fi
}
main "$@"