Python release workflow
Table of Contents
Python release process
Release workflow.
Don't tag a release on GitHub until all the tests pass,
the package contents are what we want and expect, and
you have run push-release
to pypi testing and checked it.
Once they do, tag it with the version that you set below
so that everything is on the same page. If there are multiple
packages per repo the tag is usually prefixed with the module name.
Note that if you use the --local ~/path/to/my/working/repo
option as the source repo
then git pull
is called with --force
since the assumption is that git commit --amend
may be used in certain cases.
NEVER USE THESE FUNCTIONS ON YOUR WORKING REPO, YOU WILL LOOSE ANY STASHED WORK OR UNTRACKED FILES
WHEN YOU PUSH TO TEST
Inspect everything at https://test.pypi.org/project/${packagename}
.
MAKE SURE THE HASHES MATCH (tail hashes vs curl output)
You can also check https://test.pypi.org/project/ontquery/#files
Example release
source ~/git/pyontutils/bin/python-release-functions.sh SOMEVAR=some-value \ build-release org repo folder packagename version --some-arg PYTHONPATH=~/git/pyontutils: SCICRUNCH_API_KEY=$(cat ~/ni/dev/secrets.yaml | grep tgbugs-travis | awk '{ print $2 }') \ build-release tgbugs ontquery ontquery ontquery 0.1.0 --release exit # if try to copy paste this block terminate here to prevent dumbs push-release ontquery ~/nas/software-releases ontquery 0.1.0 # DO GitHub RELEASE HERE are-you-sure && \ final-release ~/nas/software-releases ontquery 0.1.0
This is a reasonable time to tag the release on GitHub.
Config files
[distutils] index-servers = pypi test [pypi] repository: https://upload.pypi.org/legacy/ username: your-username [test] repository: https://test.pypi.org/legacy/ username: your-username password: set-this-one-for-simplicity
Code
Python release functions
Tangle this block so you can source ./../bin/python-release-functions.sh
<<&build-release>> <<&push-release>> # TODO github-release <<&final-release>>
Build release
3.9
local POSITIONAL=() local INTEGRATION_PACKAGES=() while [[ $# -gt 0 ]] do key="$1" case $key in -l|--local) local CLONEFROM="$2"; shift; shift ;; -f|--artifact-folder) local ARTIFACT_FOLDER="$2"; shift; shift ;; -p|--base-path) local BASE_PATH="$2"; shift; shift ;; -b|--branch) local BRANCH="$2"; shift; shift ;; -i|--install-package) local INTEGRATION_PACKAGES+=("$2"); shift; shift ;; --python) local PYTHON_VERSION="$2"; shift; shift ;; --tag-no-rename) local TAG_NO_RENAME=YES; shift ;; --tag-prefix) local TAG_PREFIX=YES; shift ;; --keep-artifacts) local KEEP_ARTIFACTS=YES; shift ;; --no-test) local NO_TEST=YES; shift;; --debug) local DEBUG=YES; shift ;; *) local POSITIONAL+=("$1"); shift ;; esac done local PYTHON_VERSION=${PYTHON_VERSION:-3.9} local org=${POSITIONAL[0]} local repo=${POSITIONAL[1]} local folder=${POSITIONAL[2]} local packagename=${POSITIONAL[3]} local version=${POSITIONAL[4]} local REST=${POSITIONAL[@]:5} # remaining position passed along echo $REST if [[ ${folder} == *"/"* || -n ${TAG_PREFIX} ]]; then local tag=${packagename}-${version} local clone_target=${repo}-${packagename}-${PYTHON_VERSION} # prevent git lock collisions folder="${clone_target}/${folder#*/}" else local tag=${version} local clone_target=${repo}-${PYTHON_VERSION} folder=${clone_target} fi # TODO make sure no vars are null : ${BASE_PATH:=/tmp/python-releases} # allow override for cases where /tmp causes test failure [ -d "${BASE_PATH}" ] || mkdir -p "${BASE_PATH}" echo $org $repo $clone_target $folder $packagename $version $tag $CLONEFROM $ARTIFACT_FOLDER $BASE_PATH ${INTEGRATION_PACKAGES[@]}
build-release () { # example # build-release org repo folder packagename version # build-release tgbugs ontquery ontquery ontquery 0.0.8 local POSITIONAL=() local INTEGRATION_PACKAGES=() while [[ $# -gt 0 ]] do key="$1" case $key in -l|--local) local CLONEFROM="$2"; shift; shift ;; -f|--artifact-folder) local ARTIFACT_FOLDER="$2"; shift; shift ;; -p|--base-path) local BASE_PATH="$2"; shift; shift ;; -b|--branch) local BRANCH="$2"; shift; shift ;; -i|--install-package) local INTEGRATION_PACKAGES+=("$2"); shift; shift ;; --python) local PYTHON_VERSION="$2"; shift; shift ;; --tag-no-rename) local TAG_NO_RENAME=YES; shift ;; --tag-prefix) local TAG_PREFIX=YES; shift ;; --keep-artifacts) local KEEP_ARTIFACTS=YES; shift ;; --no-test) local NO_TEST=YES; shift;; --debug) local DEBUG=YES; shift ;; *) local POSITIONAL+=("$1"); shift ;; esac done local PYTHON_VERSION=${PYTHON_VERSION:-3.9} local org=${POSITIONAL[0]} local repo=${POSITIONAL[1]} local folder=${POSITIONAL[2]} local packagename=${POSITIONAL[3]} local version=${POSITIONAL[4]} local REST=${POSITIONAL[@]:5} # remaining position passed along echo $REST if [[ ${folder} == *"/"* || -n ${TAG_PREFIX} ]]; then local tag=${packagename}-${version} local clone_target=${repo}-${packagename}-${PYTHON_VERSION} # prevent git lock collisions folder="${clone_target}/${folder#*/}" else local tag=${version} local clone_target=${repo}-${PYTHON_VERSION} folder=${clone_target} fi # TODO make sure no vars are null : ${BASE_PATH:=/tmp/python-releases} # allow override for cases where /tmp causes test failure [ -d "${BASE_PATH}" ] || mkdir -p "${BASE_PATH}" echo $org $repo $clone_target $folder $packagename $version $tag $CLONEFROM $ARTIFACT_FOLDER $BASE_PATH ${INTEGRATION_PACKAGES[@]} cd ${BASE_PATH} # ensure we are always working in tmp for the rest of the time TEST_PATH="${BASE_PATH}/release-testing/${PYTHON_VERSION}-${packagename}" # allow multiple builds at the same time if [ -d ${repo} ]; then rm -rf "${TEST_PATH}" fi mkdir -p "${TEST_PATH}" if [ -d ${clone_target} ]; then pushd ${clone_target} rurl="$(git remote get-url origin)" if [[ -z ${CLONEFROM} && ! $rurl =~ "https://" && ! $rurl =~ "git@" ]]; then git remote set-url origin https://github.com/${org}/${repo}.git ${clone_target} elif [[ -n ${CLONEFROM} && "$rurl" != "${CLONEFROM}" ]]; then git remote set-url origin "${CLONEFROM}" fi git fetch || return $? # fail on bad clone to prevent testing against stale code git reset --hard origin/master git clean -dfx popd else if [[ -n ${CLONEFROM} ]]; then git clone ${CLONEFROM} ${clone_target} else git clone https://github.com/${org}/${repo}.git ${clone_target} fi fi # TODO __version__ check against ${version} pushd "${folder}" || return $? # or subfolder if [[ $(git tag -l ${tag}) ]]; then gsh=$(git rev-parse --short HEAD) verspath=$(grep -l '__version__.\+=' $(ls */*.py)) # this commit count doesn't quite match the one we get # from the python code which checks only files in sdist commit_count=$(git rev-list ${tag}..HEAD -- . | wc -l) version=${version}+${commit_count}.${gsh} tag=${tag}+${gsh} echo "${tag} has already been released for this repo!" echo "running with ${tag} ${version} instead" # FIXME need to make sure that we prevent releases in this case fi if [[ -n ${BRANCH} ]]; then git checkout ${BRANCH} git pull # in the event that a local branch already exists else git checkout -f master # just like clean -dfx this should wipe changes just in case fi #git checkout ${version} # only if all tests are go and release is tagged if [[ -n ${verspath} ]]; then # apply local version after checkout sed -i '/__version__/d' "${verspath}" # handle bad semantics for find_version echo "__version__ = '${version}'" >> "${verspath}" fi ## build release artifacts PYTHONPATH=${PYTHONPATH}$(realpath .) python setup.py sdist $REST # pass $REST along eg for --release if [ $? -ne 0 ]; then echo "setup.py failed" popd > /dev/null return 1 fi # build the wheel from the sdist NOT from the repo pushd dist/ tar xvzf ${packagename}-${version}.tar.gz pushd ./${packagename}-${version}/ python setup.py bdist_wheel $@ # this should NOT be $REST, because we don't call it with --release (among other things) mv dist/*.whl ../ popd # from ./${packagename}-${version}/ rm -r ./${packagename}-${version}/ popd # from dist/ ## testing if [[ -z ${NO_TEST} ]]; then unset PYTHONPATH cp dist/${packagename//-/*}-${version}* "${TEST_PATH}" pushd "${TEST_PATH}" tar xvzf ${packagename}-${version}.tar.gz if [ $? -ne 0 ]; then echo "tar failed, probably due to a version mismatch" popd > /dev/null popd > /dev/null return 1 fi pushd ${packagename}-${version} # pipenv --rm swears no venv exists, if no Pipfile # exists even if adding a Pipfile will magically # reveal that there was in fact a venv and thus that # every other pipenv command knows about it but # naieve little rm is kept in the dark, so we yell # into the 'void' just to make sure touch Pipfile # FIXME need a way to do concurrent builds on different python versions # running pipenv --rm breaks that pipenv --rm # clean any existing env pipenv --python $PYTHON_VERSION # for some reason 3.6 lingers in some envs if [[ -n ${DEBUG} ]]; then pipenv run pip install pudb ipdb # install both for simplicity NOCAP='-s' fi # local package server local maybe_eiu=() if [[ -n ${ARTIFACT_FOLDER} ]]; then #pipenv run pip install requests-file || return $? # sadly this does not work #--extra-index-url "file://$(realpath ${ARTIFACT_FOLDER})" \ # run a local pip package server for integration testing # it would be great to be able to pass 0 for the port to http.server # but http.server doesn't flush stdout correctly until process exit # so we use socket to get a random port and the use that and hope # that some other process doesn't randomly grab it in between # spoilers: some day it will PORT=$(python -c 'import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()') python -m http.server \ $PORT \ --bind 127.0.0.1 \ --directory "${ARTIFACT_FOLDER}" \ > /dev/null 2>&1 & # if you need to debug redirect somewhere other than /dev/null local TOKILL=$! maybe_eiu+=(--extra-index-url "http://localhost:${PORT}") fi if [[ -n ${INTEGRATION_PACKAGES} ]]; then echo $(color yellow)installing integration packages$(color off) ${INTEGRATION_PACKAGES[@]} pipenv run pip install \ "${maybe_eiu[@]}" \ ${INTEGRATION_PACKAGES[@]} || return $? fi echo $(color yellow)installing$(color off) ${packagename} pipenv run pip install \ "${maybe_eiu[@]}" \ -e .[test] || local CODE=$? [[ -n $TOKILL ]] && kill $TOKILL [[ -n $CODE && $CODE -ne 0 ]] && return $CODE pipenv run pytest ${NOCAP} || local FAILURE=$? # FIXME popd on failure ... can't && because we loose the next popd instead of exiting # everything should pass if not, keep going until it does popd # from ${packagename}-${version} popd # from "${TEST_PATH}" else # treat unrun tests as if they failed echo "$(color yellow)TESTS WERE NOT RUN$(color off)"; local FAILURE=1 fi # background here to twine? popd # from "${folder}" if [[ -n ${FAILURE} ]]; then echo "$(color red)TESTS FAILED$(color off)"; fi # deposit the build artifacts if [[ -n ${ARTIFACT_FOLDER} ]]; then if [ ! -d "${ARTIFACT_FOLDER}/${packagename}" ]; then mkdir -p "${ARTIFACT_FOLDER}/${packagename}" fi cp "${folder}"/dist/${packagename//-/*}-${version}* "${ARTIFACT_FOLDER}/${packagename}" echo "build artifacts have been copied to ${ARTIFACT_FOLDER}/${packagename}" fi # FIXME need multiple repos when packages share a repo # basically a test for if [[ package == repo ]] or something if [[ -n ${KEEP_ARTIFACTS} ]]; then echo "$(color yellow)keeping artifacts$(color off)" elif [[ -n ${CLONEFROM} || ${BRANCH} ]]; then rm ${folder}/dist/${packagename//-/*}-${version}* if [[ -n ${CLONEFROM} ]]; then echo "$(color yellow)release build was cloned from a local source$(color off) ${CLONEFROM}" else echo "$(color yellow)release build was cloned from a specific branch$(color off) ${BRANCH}" fi echo "$(color ltyellow)removing the build artifacts from ${folder}/dist$(color off)" echo "$(color ltyellow)to prevent release from a private source$(color off)" fi }
Push release
function push-release () { # example # push-release folder software_releases_path packagename version # push-release ontquery ~/nas/software-releases ontquery 0.0.8 local folder=$1 shift local software_releases_path=$1 shift local packagename=$1 shift local version=$1 shift local PYTHON_VERSION=${PYTHON_VERSION:-3.9} local repo=${folder%/*} # XXX this more or less matches current conventions if [[ ${folder} == *"/"* ]]; then local clone_target=${repo}-${packagename}-${PYTHON_VERSION} # prevent git lock collisions folder="${clone_target}/${folder#*/}" else local clone_target=${repo}-${PYTHON_VERSION} folder=${clone_target} fi # NOTE Always deploy from ${folder}/dist NOT from ARTIFACT_FOLDER # This prevents accidental release of testing builds rsync -a -v --ignore-existing ${folder}/dist/${packagename//-/*}-${version}{-,.tar}* ${software_releases_path}/ || return $? pushd ${software_releases_path} sha256sum ${packagename//-/*}-${version}{-,.tar}* >> hashes twine upload --repository test ${packagename//-/*}-${version}{-,.tar}* || return $? sleep 1 echo "test pypi hashes" curl https://test.pypi.org/pypi/${packagename}/json | python -m json.tool | grep "\(sha256\|filename\)" | grep -B1 "${version}" | awk '{ gsub(/"/, "", $2); printf("%s ", $2) }' | sed 's/,\ /\n/g' echo "local hashes" grep "${packagename//-/.}-${version}" hashes echo go inspect https://test.pypi.org/project/${packagename} echo and go do the github release popd }
TODO GitHub release
import requests from sparcur.utils #from sparcur.utils import mimetype # FIXME or something like that # TODO api token suffix_to_mime = { '.whl': 'application/octet-stream', # technically zip ... '.gz': 'application/gzip', '.zip': 'application/zip', } class BadAssetSuffixError(Exception): """ u wot m8 !? """ def upload_assets(upload_base, version, *asset_paths): for asset in asset_paths: name = asset.name requests.post() def github_release(org, repo, version, hashes, *assets, branch='master'): """ hashes should be the output of sha256sum {packagename}-{version} """ # FIXME pyontutils violates some assumptions about 1:1 ness here asset_paths = tuple(Path(a).resolve() for a in assets) bads = [p.suffix for p in asset_paths if p.suffix not in suffix_to_mime] if bads: raise BadAssetSuffixError(' '.join(bads)) base = 'https://api.github.com' path = f'/repos/{org}/{repo}/releases' headers = {'Accept': 'application/vnd.github.v3+json'} json_data = {'tag_name': version, 'target_commitish': branch, 'name': version, 'body': hashes, 'draft': False, # ok because we can add assets later 'prerelease': False} url = base + path resp = requests.post(url, headers=headers, json=json_data) rel_J = resp.json() uu = rel_j['upload_url'] upload_base = uu.replace('{?name,label}', '') upload_assets(upload_base, *asset_paths)
Final release
function final-release () { # example # final-release software_releases_path packagename version # final-release ~/nas/software-releases ontquery 0.0.8 local software_releases_path=$1 shift local packagename=$1 shift local version=$1 shift pushd ${software_releases_path} twine upload --repository pypi ${packagename/-/*}-${version}{-,.tar}* || return $? # enter password here sleep 1 echo "pypi hashes" curl https://pypi.org/pypi/${packagename}/json | python -m json.tool | grep "\(sha256\|filename\)" | grep -B1 "${version}" | awk '{ gsub(/"/, "", $2); printf("%s ", $2) }' | sed 's/,\ /\n/g' echo "local hashes" grep "${packagename}-${version}" hashes echo go inspect https://pypi.org/project/${packagename} popd }
Utils
function are-you-sure () { read -p "Are you sure you want to push the final release? yes/N " -n 1 choice # (((( case "${choice}" in yes|YES) echo ;; n|N) echo; echo "Not pushing final release."; return 1;; '?') echo; echo "$(set -o posix; set | grep -v '^_')"; return 1;; *) echo; echo "Not pushing final release."; return 1;; esac echo "Pushing final release ..." }
Examples
These are examples. They may be out of date and already finished.
build-release tgbugs pyontutils pyontutils/librdflib librdflib 0.0.1
push-release pyontutils/librdflib ~/nas/software-releases librdflib 0.0.1
final-release ~/nas/software-releases librdflib 0.0.1
build-release tgbugs pyontutils pyontutils/htmlfn htmlfn 0.0.1
push-release pyontutils/htmlfn ~/nas/software-releases htmlfn 0.0.1
final-release ~/nas/software-releases htmlfn 0.0.1
build-release tgbugs pyontutils pyontutils/ttlser ttlser 1.0.0
push-release pyontutils/ttlser ~/nas/software-releases ttlser 1.0.0
final-release ~/nas/software-releases ttlser 1.0.0
build-release tgbugs pyontutils pyontutils pyontutils 0.1.2
push-release pyontutils ~/nas/software-releases pyontutils 0.1.2
final-release ~/nas/software-releases pyontutils 0.1.2
NIFSTD_CHECKOUT_OK=1 build-release tgbugs pyontutils pyontutils/neurondm neurondm 0.1.0
push-release pyontutils/neurondm ~/nas/software-releases neurondm 0.1.0
final-release ~/nas/software-releases neurondm 0.1.0
build-release tgbugs pyontutils pyontutils/nifstd nifstd-tools 0.0.1
pyontutils full repo release testing
NOTE if you reuse a repo run git clean -dfx
to clear all untracked files.
pushd /tmp git clone https://github.com/tgbugs/pyontutils.git pushd pyontutils python setup.py sdist; cp dist/pyontutils* /tmp/release-testing for f in {librdflib,htmlfn,ttlser,neurondm,nifstd}; do pushd $f; python setup.py sdist; cp dist/$f* /tmp/release-testing/; popd; done pushd /tmp/release-testing find -name "*.tar.gz" -exec tar xvzf {} \; for f in {librdflib,htmlfn,ttlser,pyontutils,neurondm,nifstd}; do pushd $f*/; pip install -e .[test]; python setup.py test; popd; done
From inside /tmp/${repo}
pushd dist/ tar xvzf pyontutils*.tar.gz pushd pyontutils*/ python setup.py bdist_wheel mv dist/*.whl ../ popd rm -r ./pyontutils*/ popd for f in {librdflib,htmlfn,ttlser,neurondm,nifstd}; do pushd $f/dist tar xvzf $f*.tar.gz pushd $f*/ python setup.py bdist_wheel mv dist/*.whl ../ popd rm -r ./$f*/ popd done