diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 694a6b3..b3b907c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,29 @@ jobs: run: | mypy serializable + sdist: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build + python -m build + + - name: Run the tests shipped in the sdist + run: | + pip install dist/*.whl pytest + mkdir sdist + tar -xzf dist/*.tar.gz -C sdist --strip-components=1 + cd sdist && python -m pytest tests -q -p no:cacheprovider + test: runs-on: ubuntu-latest strategy: diff --git a/AGENTS.md b/AGENTS.md index ad25914..da9b34c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Do not tell the user you are "done" or that changes are "complete" until all thr - `./format.sh` — Formats code with ruff (run this first) - `./lint.sh` — Checks linting and formatting (must pass). **Always use this for linting if it exists.** - `./test.sh` — Runs pytest with coverage (must pass) -- `./deploy.sh` — Builds the distribution and uploads to PyPI via `twine` (gates on `lint.sh` and `test.sh`). **Always use this for deploying if it exists.** +- `./deploy.sh` — From a clean `master` that matches `origin`: gates on `lint.sh` and `test.sh`, builds, uploads to PyPI via `twine`, then tags `v` and pushes the tag. `./deploy.sh --dry-run` stops before the upload. **Always use this for deploying if it exists.** - `./develop.sh` — Installs package in development mode into `.venv` ## Code Style diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1d6095a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +# setuptools only adds tests/test*.py to the sdist by default; ship the whole +# suite (tests/__init__.py, tests/common.py) so tests run from the tarball. +graft tests +global-exclude *.py[cod] diff --git a/deploy.sh b/deploy.sh index 61a61a3..08b813a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,15 +1,75 @@ #!/usr/bin/env bash +# Publish the merged version to PyPI and tag it. Adapted from varcode's deploy.sh. +# Override the interpreter with DEPLOY_PYTHON=/path/to/python ./deploy.sh set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$ROOT" +usage() { + echo "Usage: ./deploy.sh [--dry-run] [version]" + echo "Publish the merged version; an optional version must match serializable/version.py." +} +fail() { echo "deploy.sh: $*" >&2; exit 1; } + +dry_run=0 +expected_version="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) dry_run=1 ;; + -h|--help) usage; exit 0 ;; + -*) fail "Unknown option: $1" ;; + *) + [[ -z "$expected_version" ]] || fail "Expected at most one version." + expected_version="${1#v}" + [[ -n "$expected_version" ]] || fail "Expected a release version." + ;; + esac + shift +done + +cd "$(dirname "$0")" +PYTHON="${DEPLOY_PYTHON:-python3}" +branch="$(git symbolic-ref --quiet --short HEAD)" || fail "Deploy from master, not a detached HEAD." +[[ "$branch" == master ]] || fail "Deploys require master (current: $branch)." +commit="$(git rev-parse HEAD)" +version="$("$PYTHON" -c 'import runpy; print(runpy.run_path("serializable/version.py")["__version__"])')" +[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || fail "Invalid release version: $version" +[[ -z "$expected_version" || "$expected_version" == "$version" ]] || fail "Requested $expected_version, but the merged version is $version. Bump versions in a PR." +tag="v$version" + +# Check live origin refs, not a possibly stale remote-tracking branch. +check_release() { + local status remote_branch remote_tag + status="$(git status --porcelain --untracked-files=all)" + [[ -z "$status" ]] || fail "Working tree is not clean." + [[ "$(git symbolic-ref --quiet --short HEAD)" == "$branch" && "$(git rev-parse HEAD)" == "$commit" ]] || fail "Checkout changed during release." + remote_branch="$(git ls-remote --exit-code origin "refs/heads/$branch")" || fail "Cannot read origin/$branch." + [[ "${remote_branch%%$'\t'*}" == "$commit" ]] || fail "HEAD must match live origin/$branch; pull the merged release first." + if git show-ref --verify --quiet "refs/tags/$tag"; then + fail "Tag $tag already exists locally. Verify the publication state before retrying." + fi + remote_tag="$(git ls-remote origin "refs/tags/$tag")" || fail "Cannot check release tags on origin." + [[ -z "$remote_tag" ]] || fail "Tag $tag already exists on origin." +} + +check_release ./lint.sh ./test.sh -PYTHON_BIN="${DEPLOY_PYTHON:-python3}" -"$PYTHON_BIN" -m pip install --upgrade build twine -rm -rf dist build ./*.egg-info -"$PYTHON_BIN" -m build -"$PYTHON_BIN" -m twine check dist/* -"$PYTHON_BIN" -m twine upload dist/* +# Keep each attempt's original bytes, including after a failed/partial upload. +mkdir -p dist +artifact_dir="$(mktemp -d "dist/$tag.XXXXXX")" +echo "Building $tag at $commit; artifacts: $artifact_dir" +"$PYTHON" -m build --outdir "$artifact_dir" +distributions=("$artifact_dir/serializable-$version-py3-none-any.whl" "$artifact_dir/serializable-$version.tar.gz") +"$PYTHON" -m twine check --strict "${distributions[@]}" +check_release + +if [[ "$dry_run" -eq 1 ]]; then + echo "Dry run passed for $tag; no upload, tag, or push. Artifacts: $artifact_dir" + exit 0 +fi + +"$PYTHON" -m twine upload --non-interactive --disable-progress-bar "${distributions[@]}" +git tag "$tag" "$commit" +git push origin "refs/tags/$tag" +echo "Deployed serializable $version: https://pypi.org/project/serializable/$version/" diff --git a/pyproject.toml b/pyproject.toml index cb60156..0afcfd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=64", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -37,7 +37,6 @@ dev = [ "pytest-cov", "ruff", "mypy", - "coveralls", "build", "twine", ] diff --git a/serializable/version.py b/serializable/version.py index 06549ac..156ccc6 100644 --- a/serializable/version.py +++ b/serializable/version.py @@ -1,4 +1,4 @@ -__version__ = "1.3.0" +__version__ = "1.3.1" def print_version(): diff --git a/setup.py b/setup.py deleted file mode 100644 index 034f013..0000000 --- a/setup.py +++ /dev/null @@ -1,6 +0,0 @@ -from setuptools import setup - - -if __name__ == "__main__": - # Delegate metadata to pyproject.toml. - setup()