This is an automated email from the ASF dual-hosted git repository. yihua pushed a commit to branch release/0.5.x in repository https://gitbox.apache.org/repos/asf/hudi-rs.git
commit 150bccd087ab1e924c5efd96f7ae5e508eb7c885 Author: Y Ethan Guo <[email protected]> AuthorDate: Sat Sep 5 08:22:54 2026 -0700 feat(release): verify the artifacts a release actually published (#759) (cherry picked from commit ed3653b42fab43a6747b1031c5d5e0bfde4fac67) --- release/README.md | 36 +++- release/verify_published_artifacts.sh | 314 ++++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+), 2 deletions(-) diff --git a/release/README.md b/release/README.md index 705f7a66..a3f3c0c2 100644 --- a/release/README.md +++ b/release/README.md @@ -47,6 +47,7 @@ This issue tracks the release process as instructed in the [release guide](https - [ ] Upload the target RC artifacts to the ASF dev repo (SVN) - [ ] Verify the target RC artifacts - [ ] Push a desired RC release git tag to the release branch +- [ ] Verify the published crates.io and pypi.org artifacts - [ ] Start VOTE in `[email protected]` > [!IMPORTANT] @@ -56,6 +57,7 @@ This issue tracks the release process as instructed in the [release guide](https - [ ] Bump the version in the release branch for the official release - [ ] Push the official release git tag to the release branch +- [ ] Verify the published crates.io and pypi.org artifacts - [ ] Upload the release artifacts to the ASF release repo (SVN) - [ ] Merge a PR to update the changelog in `main` branch - [ ] Publish release notes in https://github.com/apache/hudi-rs/releases @@ -172,7 +174,31 @@ RELEASE_VER=x.y.z-rc.1 git push origin release-$RELEASE_VER ``` -Once the CI completes, check crates.io and pypi.org for the new release artifacts. +### Verify the published artifacts + +Once the CI completes, check that every artifact the publish workflow builds actually reached +crates.io and pypi.org, and that the wheel works once installed. + +```shell +RELEASE_VER=x.y.z-rc.1 + +./release/verify_published_artifacts.sh $RELEASE_VER +``` + +This checks all three crates on crates.io, the full wheel set plus the sdist on pypi.org, and then +installs and reads a table with each wheel it can reach from the machine you run it on: both linux +wheels through docker, and the macOS wheel when run on macOS. Pass `--skip-functional` to skip the +install-and-read tests. + +It exits 0 when everything it checked passed, 1 when an artifact is missing or broken, and 2 when a +check could not run at all, which is a reason to re-run rather than to cut a new release candidate. +Anything it could not cover is named in the output. + +> [!IMPORTANT] +> A publish job can fail after its siblings have already uploaded, which leaves a version that looks +> published but is missing a platform. crates.io does not allow re-publishing a version, so a gap +> found here means starting a new release candidate, not repairing this one. Do not start the VOTE +> thread until this passes. ### Generate changelog @@ -303,7 +329,13 @@ RELEASE_VER=x.y.z git push origin release-$RELEASE_VER ``` -Once the CI completes, check crates.io and pypi.org for the new release artifacts. +Once the CI completes, verify the published artifacts again for the official version. + +```shell +RELEASE_VER=x.y.z + +./release/verify_published_artifacts.sh $RELEASE_VER +``` ### Update the change log diff --git a/release/verify_published_artifacts.sh b/release/verify_published_artifacts.sh new file mode 100755 index 00000000..2fc2b2fe --- /dev/null +++ b/release/verify_published_artifacts.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Verify the artifacts a release tag published to crates.io and pypi.org. +# +# verify_src_release.sh covers the source release, which is the ASF release +# proper. This covers the convenience binaries, which are what almost everyone +# actually installs, and which the source checks say nothing about: whether +# every platform is present, whether the wheel imports, and whether it reads a +# table once loaded. +# +# Run it after the publish workflow finishes and before starting the VOTE +# thread. A publish job that fails after its siblings have uploaded leaves a +# version that looks published and is missing a platform, and crates.io will +# not take that version again, so the gap has to be found before anyone votes. +# +# Run it from the release branch: the functional test reads a table fixture out +# of the checkout, and a fixture written for a newer table version than the +# release supports fails for that reason rather than for anything about the +# artifact. + +set -euo pipefail + +usage() { + echo "Usage: $0 <version> [--skip-functional]" + echo + echo " version release version, e.g. 0.5.0 or 0.5.0-rc.2" + echo " --skip-functional skip the install-and-read tests" + exit 1 +} + +[ "$#" -ge 1 ] || usage + +version=$1 +skip_functional=false +[ "${2:-}" = "--skip-functional" ] && skip_functional=true + +version_pattern="^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$" +if [[ ! "$version" =~ $version_pattern ]]; then + echo "ERROR: version must be X.Y.Z or X.Y.Z-{alpha|beta|rc}.W" + exit 1 +fi + +# PyPI normalizes the pre-release suffix: 0.5.0-rc.1 is published as 0.5.0rc1. +pypi_version=$(echo "$version" | + sed -E 's/-alpha\.([0-9]+)$/a\1/; s/-beta\.([0-9]+)$/b\1/; s/-rc\.([0-9]+)$/rc\1/') + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +work_root=$(mktemp -d) +trap 'rm -rf "$work_root"' EXIT + +failures=0 +note_failure() { + echo " $1" + failures=$((failures + 1)) +} + +# A check that could not run is not a broken release. Counted separately so the +# summary can say the run was incomplete without sending anyone to cut a new RC. +unknowns=0 +note_unknown() { + echo " $1" + unknowns=$((unknowns + 1)) +} + +echo "Verifying published artifacts for $version (pypi: $pypi_version)" + +# ---------------------------------------------------------------- crates.io -- +# The three crates publish in one cargo invocation, so a partial set means the +# publish died part-way and the version can never be re-published. +echo ">>> Verifying crates.io..." +for crate in hudi-core hudi-datafusion hudi; do + # Distinguish a registry we could not reach from a version that is not there. + # Only the second means the release is broken, and the remedy for that is a + # whole new release candidate. + body=$work_root/crates-$crate.json + http_code=$(curl -s -o "$body" -w '%{http_code}' \ + -H 'User-Agent: hudi-rs release verification' \ + "https://crates.io/api/v1/crates/$crate/$version" || true) + case "$http_code" in + 200) + if grep -q "\"num\":\"$version\"" "$body"; then + echo " $crate $version: present" + else + note_failure "$crate $version: MISSING from crates.io" + fi + ;; + 404) + note_failure "$crate $version: MISSING from crates.io" + ;; + *) + note_unknown "$crate $version: not checked (crates.io returned $http_code)" + ;; + esac +done + +# -------------------------------------------------------------------- PyPI -- +# Every platform the release workflow builds has to be here. A missing wheel +# sends that platform to the sdist, which needs protoc and a C++ toolchain, so +# in practice it is an install failure rather than a slow install. +echo ">>> Verifying pypi.org..." +pypi_body=$work_root/pypi.json +pypi_code=$(curl -s -o "$pypi_body" -w '%{http_code}' \ + "https://pypi.org/pypi/hudi/$pypi_version/json" || true) +if [ "$pypi_code" = "404" ]; then + note_failure "hudi $pypi_version: NOT FOUND on pypi.org" +elif [ "$pypi_code" != "200" ]; then + note_unknown "hudi $pypi_version: not checked (pypi.org returned ${pypi_code:-no response})" +else + pypi_json=$(cat "$pypi_body") + filenames=$(echo "$pypi_json" | python3 -c \ + 'import json,sys; print("\n".join(f["filename"] for f in json.load(sys.stdin)["urls"]))') + # The floor the wheels were built against, so a leg can tell "this interpreter + # is too old" apart from "this wheel is broken". Only the lower bound matters + # here, and a spec this cannot read yields nothing rather than a wrong number: + # a leg that cannot establish the floor runs anyway rather than skipping on a + # parse it got wrong. + pypi_requires_python=$(echo "$pypi_json" | python3 -c \ + 'import json,sys; print(json.load(sys.stdin)["info"].get("requires_python") or "")') + pypi_floor=$(python3 - "$pypi_requires_python" <<'FLOOR_PY' +import re +import sys + +spec = sys.argv[1] if len(sys.argv) > 1 else "" +match = re.search(r">=\s*(\d+)\.(\d+)", spec) +print(f"{match.group(1)}.{match.group(2)}" if match else "") +FLOOR_PY +) + + # Substrings rather than exact names: the abi3 tag tracks the python floor and + # the manylinux tag tracks the build image, and neither should fail this check + # when it changes deliberately. + for expected in \ + "macosx_.*_x86_64\.whl" \ + "macosx_.*_arm64\.whl" \ + "win_amd64\.whl" \ + "manylinux.*_x86_64\.whl" \ + "manylinux.*_aarch64\.whl" \ + "\.tar\.gz"; do + if echo "$filenames" | grep -qE "$expected"; then + echo " $(echo "$filenames" | grep -E "$expected" | head -n 1)" + else + note_failure "no artifact matching '$expected'" + fi + done +fi + +# -------------------------------------------------------------- functional -- +# Installing in a clean environment is the part that catches a wheel that builds +# and then does not load: the rocksdb bindings come from the build container's +# libclang, and bad bindings surface on import or first read, not at compile +# time. Reading a table exercises the merge path rather than just the module. +# +# A wheel is per-platform, so each one has to be installed on its own platform. +# A leg that cannot run here says so: it must not be reported as passing, and an +# environment that cannot run a leg must not be reported as a broken release. +if [ "$skip_functional" = true ]; then + echo ">>> Skipping the install-and-read tests (--skip-functional)" +else + echo ">>> Verifying the wheels install and read a table..." + work_dir=$work_root + have_fixture=true + fixture=$repo_root/crates/test/data/quickstart_trips_table/mor/avro/v8_trips_8i3u1d.zip + if [ ! -f "$fixture" ]; then + note_failure "table fixture not found at $fixture" + have_fixture=false + else + unzip -q "$fixture" -d "$work_dir/tables" + fi + + # One copy of the read, run by every leg. Two copies drift, and the check that + # a leg read anything at all is the one worth not losing to that. + check_py=$work_dir/check.py + cat >"$check_py" <<'CHECK_PY' +import glob +import os +import sys + +import hudi + +root = sys.argv[1] +tables = [p for p in sorted(glob.glob(f"{root}/*")) if os.path.isdir(f"{p}/.hoodie")] +if not tables: + sys.exit(f"no table fixture found under {root}") + +for path in tables: + table = hudi.HudiTable(path) + rows = sum(batch.num_rows for batch in table.read()) + if rows == 0: + sys.exit(f"{os.path.basename(path)}: read returned no rows") + print(f" {os.path.basename(path)}: {table.table_type}, {rows} rows, " + f"{len(table.get_schema())} columns") +CHECK_PY + + # Track the wheels' own floor rather than pinning a version that a later abi3 + # bump would leave below it, which would read as a broken wheel. + python_image=python:${pypi_floor:-3.11}-slim + + if [ "${pypi_code:-}" != "200" ]; then + # The metadata check already called this an unknown. pip cannot reach the + # index either, so letting the legs run would turn one outage into several + # failures and a verdict of "cut a new release candidate". + echo " linux: not tested (could not reach pypi.org)" + elif [ "$have_fixture" != true ]; then + echo " linux: not tested (no table fixture)" + elif ! command -v docker >/dev/null 2>&1; then + echo " linux: not tested (docker not found)" + elif ! docker info >/dev/null 2>&1; then + # Without this, the per-architecture probe below swallows the daemon error + # and reports both wheels as an architecture this host cannot run, which is + # both untrue and silent about the two artifacts that motivated this script. + echo " linux: not tested (docker found but the daemon is not reachable)" + else + # A stock python image, not the image the wheel was built in: that is what + # makes this a test of the platform tag rather than of the build container. + for docker_platform in linux/amd64 linux/arm64; do + # An architecture this host cannot execute is a missing binfmt handler, + # not a bad wheel. The two must not reach the same verdict, because the + # remedy for a bad wheel is an entire new release candidate. + if ! docker run --rm --platform "$docker_platform" "$python_image" true >/dev/null 2>&1; then + echo " $docker_platform: not tested (this host cannot run that architecture)" + continue + fi + echo " $docker_platform:" + if docker run --rm --platform "$docker_platform" \ + -v "$work_dir/tables:/data:ro" \ + -v "$check_py:/check.py:ro" \ + "$python_image" bash -c " + set -e + pip install --quiet --only-binary=:all: 'hudi==$pypi_version' + python -c 'import hudi; print(\" import: ok\")' + python /check.py /data + "; then + echo " installs and reads: ok" + else + note_failure "$docker_platform: the wheel failed to install, import, or read a table" + fi + done + fi + + # The macOS wheel needs no container, so it must not sit behind the docker + # check: on a mac without docker this is the one leg that can still run. + if [ "${pypi_code:-}" != "200" ]; then + echo " macos: not tested (could not reach pypi.org)" + elif [ "$have_fixture" != true ]; then + echo " macos: not tested (no table fixture)" + elif [ "$(uname -s)" != "Darwin" ]; then + echo " macos: not tested (run this on macOS to cover it)" + elif ! command -v python3 >/dev/null 2>&1; then + echo " macos: not tested (python3 not found)" + elif [ -n "$pypi_floor" ] && ! python3 -c " +import sys +sys.exit(0 if sys.version_info >= tuple(int(p) for p in '$pypi_floor'.split('.')) else 1) +"; then + # The container leg picks an interpreter; this one takes what PATH gives it, + # and macOS still ships 3.9. Below the wheels' floor pip reports no matching + # distribution, which is this interpreter being too old rather than anything + # wrong with the wheel. + echo " macos: not tested (python3 is $(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))'), the wheels need >= $pypi_floor)" + else + echo " macos/$(uname -m):" + venv=$work_dir/venv + python3 -m venv "$venv" + if "$venv/bin/pip" install --quiet --only-binary=:all: "hudi==$pypi_version" && + "$venv/bin/python" -c 'import hudi; print(" import: ok")' && + "$venv/bin/python" "$check_py" "$work_dir/tables"; then + echo " installs and reads: ok" + else + note_failure "macos/$(uname -m): the wheel failed to install, import, or read a table" + fi + fi + + # Name what is still uncovered, so the legs that did run are not read as + # standing in for the whole set the presence check above lists. + if [ "$(uname -s)" = "Darwin" ]; then + if [ "$(uname -m)" = "arm64" ]; then + echo " macos/x86_64: not tested (needs an intel mac)" + else + echo " macos/arm64: not tested (needs an apple silicon mac)" + fi + fi + echo " windows: not tested (no way to exercise it from here)" +fi + +echo +if [ "$failures" -ne 0 ]; then + echo "FAILED: $failures problem(s) found. Do not start the VOTE thread." + if [ "$unknowns" -ne 0 ]; then + echo "($unknowns further check(s) could not run, so this run was also incomplete.)" + fi + exit 1 +fi +if [ "$unknowns" -ne 0 ]; then + echo "INCOMPLETE: $unknowns check(s) could not run. Nothing looks broken, but" + echo "this run did not cover everything; re-run it before starting the VOTE thread." + exit 2 +fi +echo "OK: published artifacts for $version look complete and usable."
