From bc68c4c095d9c5b389022bf568c2672de9b99c0b Mon Sep 17 00:00:00 2001 From: Pete Moore Date: Thu, 30 Apr 2026 10:48:22 +0200 Subject: [PATCH] release.sh: batch preflight, defer push-after-publish, modern build Restructure release.sh into a preflight phase that collects all environment, credential and repo-state problems and reports them together, plus a release phase that publishes to PyPI and Docker Hub before pushing the commit and signed tag to GitHub. New preflight checks: - Required binaries: docker, pass, gpg, python3 - Docker daemon reachable + docker buildx available - GPG secret key present (required for git tag -s) - pass entries 'community-tc/secret-values.yml' (with the 'tc-admin-release-pypi-password' line) and 'hub.docker.com/taskclusterbot' both exist - Branch is explicitly 'main' (the prior SHA-only check passed on a feature branch sitting at the same commit as main) Reorder so 'git push' happens AFTER the PyPI upload and Docker Hub push succeed. A publish failure now leaves the remote untouched, making recovery a matter of 'git reset --hard HEAD~1 && git tag -d v' rather than chasing a half-released tag. Switch the build from the deprecated 'setup.py sdist / bdist_wheel' to the PEP-517 frontend ('python -m build'), and run 'twine check' on the built distributions before uploading so common metadata problems (bad README rendering, invalid classifiers, ...) are caught locally. Drop the '--real' / '-r' flag. It only redirected PyPI between the test and real indexes; Docker Hub and GitHub were always published to the real registries, so the flag did not provide a true dry-run mode and was misleading. End-to-end test-PyPI verification can be done with a manual one-liner outside the release script (documented in RELEASING.md). Add RELEASING.md documenting prerequisites, the workflow each phase executes, how to verify a release before publishing, and how to recover from a failed run. Co-Authored-By: Claude Opus 4.7 --- RELEASING.md | 120 ++++++++++++++++++++++++++ release.sh | 233 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 285 insertions(+), 68 deletions(-) create mode 100644 RELEASING.md diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..afaa436 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,120 @@ +# Making a release + +The `release.sh` script is the only supported way to cut a release of +`tc-admin`. It bumps the version, builds Python distributions, publishes +to PyPI and Docker Hub, and pushes a signed tag to GitHub. + +## Prerequisites + +You need the following tools available on `PATH`: + +| Tool | Purpose | +|------|---------| +| `git` | committing, tagging, pushing | +| `python3` | building the sdist / wheel | +| `docker` (with `buildx`) | building the multi-arch image | +| `pass` | retrieving PyPI / Docker Hub credentials | +| `gpg` | signing the git tag (`git tag -s`) | + +You must also have: + +- A GPG secret key — required to sign the git tag. +- An SSH key with push access to `git@github.com:taskcluster/tc-admin`. +- An entry `community-tc/secret-values.yml` in your `pass` store containing + a `tc-admin-release-pypi-password:` line. +- An entry `hub.docker.com/taskclusterbot` in your `pass` store containing + the Docker Hub password for the `taskclusterbot` account. +- Maintainer rights on: + - https://pypi.org/project/tc-admin/ + - https://hub.docker.com/r/taskcluster/tc-admin + - https://github.com/taskcluster/tc-admin + +The script's pre-flight phase verifies most of the above before doing +any destructive work, and reports all problems together so you can fix +them in a single pass. + +You must run the script with **no Python virtualenv active** — it builds +its own venv under `.release/py3/`. + +## Running + +```bash +./release.sh --version 1.2.3 +``` + +The version must match `..` where `a >= 1`, `b >= 0`, `c >= 0`, +all integers, no leading zeros. An optional `alpha` suffix is allowed +on the patch component. + +## What the script does + +1. **Pre-flight checks** — verifies tools (`git`, `python3`, `docker`, + `pass`, `gpg`), Docker daemon and `buildx`, GPG secret key, + `pass` entries, version-string format (old and new), branch is + `main`, working tree is clean, local HEAD matches remote `main`, + and that the tag does not already exist locally or remotely. All + problems are reported together. +2. **Version bump** — updates `setup.py` and the `tc-admin~=…` line in + `Dockerfile` via `sed` and stages the changes. +3. **Local commit + signed tag** — creates a `Version bump from X to Y` + commit and a signed `vX.Y.Z` tag locally. Nothing is pushed yet. +4. **Build** — creates a fresh virtualenv under `.release/py3/`, + installs `build` and `twine`, then runs `python -m build` to produce + the sdist and wheel in `dist/`. +5. **Validate package** — `twine check dist/*` checks that the package + metadata is valid (long_description renders cleanly on PyPI, + classifiers are recognised, etc.) before contacting the index. +6. **Publish to PyPI** — `twine upload` to https://upload.pypi.org/legacy/. + The PyPI password is retrieved from `pass` and printed for manual + paste at the prompt. +7. **Publish to Docker Hub** — `docker login`, then + `docker buildx build --platform linux/amd64,linux/arm64 --push` to + `taskcluster/tc-admin:`. The Docker Hub password is + retrieved from `pass` and printed for manual paste. +8. **Push to GitHub** — pushes the version-bump commit to `main` and + the signed tag. This is intentionally the last step so that a + publish failure leaves the remote untouched. +9. **Open release page** — opens + `https://github.com/taskcluster/tc-admin/releases/new?tag=v` + in your browser so you can write the release notes. + +## Verifying before publishing + +`twine check` (run automatically by the script) catches the most common +metadata problems — bad README rendering, unrecognised classifiers, +missing required fields — without contacting PyPI. + +For the rare case where you want to see how the package will *render +live* on PyPI before committing to a real release (typically when +materially changing `long_description`, `long_description_content_type`, +or other metadata), you can do an end-to-end test against +[test.pypi.org](https://test.pypi.org) outside the release script: + +```bash +rm -rf dist/* +python -m build +twine check dist/* +twine upload --repository testpypi dist/* +``` + +…then look at the result on https://test.pypi.org/project/tc-admin/. +Note that test PyPI does not allow re-uploading the same version, so +use a throwaway version (e.g. an `alpha` suffix) when testing. + +## Recovering from a failed run + +Because the GitHub push happens at the end, a failure during build, +PyPI upload, or Docker Hub publish leaves the remote untouched. To +recover: + +```bash +git reset --hard HEAD~1 # undo the version-bump commit +git tag -d "v" # remove the local signed tag +``` + +…then fix the underlying cause and re-run `./release.sh`. + +If the failure happens *after* a successful PyPI or Docker Hub upload, +you cannot reuse the same version number — PyPI does not allow +re-uploading a version, and the Docker tag has already been published. +In that case, cut the next patch release instead. diff --git a/release.sh b/release.sh index 453da0d..9390f3d 100755 --- a/release.sh +++ b/release.sh @@ -1,15 +1,34 @@ #!/usr/bin/env bash # This script is used to generate releases of tc-admin. It should be the only -# way that releases are created. There are two phases, the first is checking -# that the code is in a clean and working state. The second phase is modifying -# files, tagging, commiting and pushing to pypi.org, github.com and -# hub.docker.com. +# way that releases are created. +# +# Phase 1 (preflight): verify the environment, credentials and repository +# state. All problems are collected and reported together so they can be +# fixed in a single pass. +# +# Phase 2 (release): bump the version, build, publish to PyPI and Docker Hub, +# and finally push the commit and signed tag to GitHub. +# +# The git push to GitHub happens AFTER the PyPI and Docker Hub publishes +# succeed, so a publish failure leaves the remote untouched. To recover +# from a failed run: +# +# git reset --hard HEAD~1 # undo the version-bump commit +# git tag -d "v" # remove the local tag +# +# ...then fix the underlying problem and re-run. # exit in case of bad exit code or undefined var set -eu set -o pipefail +PYPI_URL='https://upload.pypi.org/legacy/' +OFFICIAL_GIT_REPO='git@github.com:taskcluster/tc-admin' + +VALID_FORMAT='^[1-9][0-9]*\.\(0\|[1-9][0-9]*\)\.\(0\|[1-9]\)\([0-9]*alpha[1-9][0-9]*\|[0-9]*\)$' +FORMAT_EXPLANATION='should be ".." where a>=1, b>=0, c>=0 and a,b,c are integers, with no leading zeros' + function usage() { echo "Usage: $0 [ .." where a>=1, b>=0, c>=0 and a,b,c are integers, with no leading zeros' +########################################################################### +# Pre-flight checks — collect all errors and report them together. +########################################################################### +function preflight() +{ + local errors=() -if ! echo "${OLD_VERSION}" | grep -q "${VALID_FORMAT}"; then - echo "Previous release version '${OLD_VERSION}' not allowed (${FORMAT_EXPLANATION}) - please fix setup.py" >&2 - exit 65 -fi + echo "=== Pre-flight checks ===" -if ! echo "${NEW_VERSION}" | grep -q "${VALID_FORMAT}"; then - echo "Release version '${NEW_VERSION}' not allowed (${FORMAT_EXPLANATION})" >&2 - exit 66 -fi + # Not running inside a virtualenv (we build our own under .release/py3) + if [ -n "${VIRTUAL_ENV:-}" ]; then + errors+=("Deactivate your virtualenv first (currently active: ${VIRTUAL_ENV})") + fi -echo "Previous release: ${OLD_VERSION}" -echo "New release: ${NEW_VERSION}" + # Required binaries + for bin in git python3 docker pass gpg; do + if ! command -v "$bin" >/dev/null 2>&1; then + errors+=("Missing binary: $bin") + fi + done -if [ "${OLD_VERSION}" == "${NEW_VERSION}" ]; then - echo "Cannot release since release version specified is the same as the current release number" >&2 - exit 67 -fi + # Docker daemon reachable + buildx available + if command -v docker >/dev/null 2>&1; then + if ! docker info >/dev/null 2>&1; then + errors+=("Docker daemon not reachable (is Docker running?)") + fi + if ! docker buildx version >/dev/null 2>&1; then + errors+=("docker buildx not available (required for multi-arch image build)") + fi + fi -# Make sure git tag doesn't already exist on remote -if [ "$(git ls-remote -t "${OFFICIAL_GIT_REPO}" "v${NEW_VERSION}" 2>&1 | wc -l | tr -d ' ')" != '0' ]; then - echo "git tag '${NEW_VERSION}' already exists remotely on ${OFFICIAL_GIT_REPO}," - echo "or there was an error checking whether it existed:" - git ls-remote -t "${OFFICIAL_GIT_REPO}" "v${NEW_VERSION}" - exit 68 -fi + # GPG secret key available (required for signed tag — git tag -s) + if command -v gpg >/dev/null 2>&1; then + if ! gpg --list-secret-keys 2>/dev/null | grep -q .; then + errors+=("No GPG secret keys found (required to sign the git tag)") + fi + fi + + # Pass entries (sync first so we're checking the latest state) + if command -v pass >/dev/null 2>&1; then + pass git pull >/dev/null 2>&1 || true + if ! pass show community-tc/secret-values.yml >/dev/null 2>&1; then + errors+=("pass entry 'community-tc/secret-values.yml' not found (needed for PyPI password)") + elif ! pass show community-tc/secret-values.yml | grep -q '^tc-admin-release-pypi-password:'; then + errors+=("pass entry 'community-tc/secret-values.yml' does not contain a 'tc-admin-release-pypi-password' line") + fi + if ! pass show hub.docker.com/taskclusterbot >/dev/null 2>&1; then + errors+=("pass entry 'hub.docker.com/taskclusterbot' not found (needed for Docker Hub login)") + fi + fi + + # Version format validation (both old and new) + if ! echo "${OLD_VERSION}" | grep -q "${VALID_FORMAT}"; then + errors+=("Previous release version '${OLD_VERSION}' not allowed (${FORMAT_EXPLANATION}) — please fix setup.py") + fi + if ! echo "${NEW_VERSION}" | grep -q "${VALID_FORMAT}"; then + errors+=("Release version '${NEW_VERSION}' not allowed (${FORMAT_EXPLANATION})") + fi + if [ "${OLD_VERSION}" == "${NEW_VERSION}" ]; then + errors+=("Cannot release: new version (${NEW_VERSION}) is the same as the current version") + fi + + # On main branch (a SHA-only check would pass on a feature branch sitting + # at the same commit as main, hence the explicit branch-name check). + local branch + branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown) + if [ "$branch" != "main" ]; then + errors+=("Not on main branch (currently on '$branch'). To fix: git checkout main") + fi -# Local changes will not be in the release, so they should be dealt with before -# continuing. git stash can help here! Untracked files can make it into release -# so let's make sure we have none of them either. -modified="$(git status --porcelain)" -if [ -n "$modified" ]; then - echo "There are changes in the local tree. This probably means" - echo "you'll do something unintentional. For safety's sake, please" - echo 'revert or stash them!' + # Working tree clean + if [ -n "$(git status --porcelain)" ]; then + errors+=("Working tree is not clean — see details below") + fi + + # Local HEAD matches remote main (and remote is reachable) + local remoteMasterSha localSha + remoteMasterSha="$(git ls-remote "${OFFICIAL_GIT_REPO}" main 2>/dev/null | cut -f1)" + if [ -z "${remoteMasterSha}" ]; then + errors+=("Cannot reach remote ${OFFICIAL_GIT_REPO} (check SSH keys / network)") + else + localSha="$(git rev-parse HEAD)" + if [ "${remoteMasterSha}" != "${localSha}" ]; then + errors+=("Local HEAD (${localSha}) does not match remote main (${remoteMasterSha}); run git pull/push first") + fi + + # Tag doesn't already exist on remote (only checkable when remote reachable) + if [ "$(git ls-remote -t "${OFFICIAL_GIT_REPO}" "v${NEW_VERSION}" 2>/dev/null | wc -l | tr -d ' ')" != '0' ]; then + errors+=("git tag v${NEW_VERSION} already exists on ${OFFICIAL_GIT_REPO}") + fi + fi + + # Tag doesn't already exist locally + if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then + errors+=("git tag v${NEW_VERSION} already exists locally") + fi + + # Report + if [ ${#errors[@]} -gt 0 ]; then + echo + echo "Pre-flight FAILED with ${#errors[@]} error(s):" + for err in "${errors[@]}"; do + echo " - $err" + done + if [ -n "$(git status --porcelain)" ]; then + echo + echo "Working tree status:" + git status --short + echo + echo "To inspect changes before discarding:" + echo " git diff # staged and unstaged changes to tracked files" + echo " git diff --cached # staged changes only" + echo " git status # full status including untracked files" + echo + echo "To discard ALL local changes (WARNING: this is irreversible):" + echo " git reset --hard HEAD # discard all changes to tracked files" + echo " git clean -fd # delete untracked files and directories" + fi + echo + exit 1 + fi + + echo " All pre-flight checks passed." echo - git status - exit 69 -fi +} -remoteMasterSha="$(git ls-remote "${OFFICIAL_GIT_REPO}" main | cut -f1)" -localSha="$(git rev-parse HEAD)" -if [ "${remoteMasterSha}" != "${localSha}" ]; then - echo "Locally, you are on commit ${localSha}." - echo "The remote taskcluster repo main branch is on commit ${remoteMasterSha}." - echo "Make sure to git push/pull so that they both point to the same commit." - exit 70 -fi +preflight + +echo "Previous release: ${OLD_VERSION}" +echo "New release: ${NEW_VERSION}" +echo + +########################################################################### +# Phase 2 — version bump, build, publish, then push to GitHub. +########################################################################### +# Bump versions in setup.py and Dockerfile inline_sed setup.py "s/\(version *= *\)\"${OLD_VERSION//./\\.}\"/\\1\"${NEW_VERSION}\"/" inline_sed Dockerfile "s/\(tc-admin *~= *\)${OLD_VERSION//./\\.}/\\1${NEW_VERSION}/" +# Local commit and signed tag (NOT yet pushed — push happens after publishes succeed) git commit -m "Version bump from ${OLD_VERSION} to ${NEW_VERSION}" git tag -s "v${NEW_VERSION}" -m "Making release ${NEW_VERSION}" -git push "${OFFICIAL_GIT_REPO}" "+HEAD:refs/heads/main" -git fetch --all -git push "${OFFICIAL_GIT_REPO}" "+refs/tags/v${NEW_VERSION}:refs/tags/v${NEW_VERSION}" -# begin making the distribution +# Build sdist + wheel using the modern PEP-517 frontend rm -f dist/* rm -rf .release mkdir -p .release python3 -mvenv .release/py3 .release/py3/bin/pip install -U pip -.release/py3/bin/pip install -U setuptools twine wheel -.release/py3/bin/python setup.py sdist -.release/py3/bin/python setup.py bdist_wheel - -# Work around https://bitbucket.org/pypa/wheel/issues/147/bdist_wheel-should-start-by-cleaning-up -rm -rf build/ +.release/py3/bin/pip install -U build twine +.release/py3/bin/python -m build ls -al dist -pass git pull +# Validate package metadata (long_description renders, classifiers valid, etc.) +# before contacting PyPI. +.release/py3/bin/twine check dist/* echo echo @@ -172,7 +264,6 @@ echo echo echo - # Publish to PyPI using Twine, as recommended by: # https://packaging.python.org/tutorials/distributing-packages/#uploading-your-project-to-pypi .release/py3/bin/twine upload --repository-url $PYPI_URL dist/* @@ -193,5 +284,11 @@ docker buildx build \ --platform linux/amd64,linux/arm64 \ -t "taskcluster/tc-admin:${NEW_VERSION}" \ --push . + +# Publishes succeeded — now push the commit and signed tag to GitHub. +git push "${OFFICIAL_GIT_REPO}" "+HEAD:refs/heads/main" +git fetch --all +git push "${OFFICIAL_GIT_REPO}" "+refs/tags/v${NEW_VERSION}:refs/tags/v${NEW_VERSION}" + echo open_url "https://github.com/taskcluster/tc-admin/releases/new?tag=v${NEW_VERSION}"