Skip to content

Commit 8b758ac

Browse files
committed
ci(sdk-python): harden py-version detection — max-over-releases + numeric-local-only
Compare against the max numeric version in PyPI `releases` rather than `info.version` (latest-uploaded, not highest). Apply strict dotted-numeric validation to the LOCAL version only; non-numeric published versions (prereleases) are filtered out instead of aborting the script. Add curl --max-time/--retry hardening and red-green test coverage for both cases.
1 parent fcc5f53 commit 8b758ac

2 files changed

Lines changed: 104 additions & 13 deletions

File tree

scripts/release/__tests__/detect-py-version-changes.test.sh

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
66
SCRIPT="${HERE}/../detect-py-version-changes.sh"
77
TMP="$(mktemp -d)"
88
SRV_PID=""
9+
STDERR_LOG="$TMP/stderr.log"
910
cleanup() { [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null || true; rm -rf "$TMP"; }
10-
trap cleanup EXIT
11+
trap cleanup EXIT INT TERM
1112

1213
# Preflight: tomllib requires py3.11+
1314
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)' \
@@ -39,21 +40,59 @@ httpd.serve_forever()
3940
PY
4041
SRV_PID=$!
4142
for _ in $(seq 1 50); do [ -s "$PORTFILE" ] && break; sleep 0.1; done
43+
if [ ! -s "$PORTFILE" ]; then
44+
echo "FAIL: fixture server failed to bind/write PORTFILE within 5s" >&2
45+
exit 1
46+
fi
4247
PORT="$(cat "$PORTFILE")"
4348
}
4449
stop_server() { kill "$SRV_PID" 2>/dev/null || true; wait "$SRV_PID" 2>/dev/null || true; SRV_PID=""; }
4550

46-
serve_published() { mkdir -p "$WWW/pypi/copilotkit"; printf '{"info":{"version":"%s"}}' "$1" > "$WWW/pypi/copilotkit/json"; }
51+
serve_published() {
52+
# Realistic PyPI-shaped response: info.version AND a releases dict (single
53+
# released version). Real PyPI always returns `releases`; the bare-info shape
54+
# was a test-only shortcut that the max-over-releases logic doesn't match.
55+
mkdir -p "$WWW/pypi/copilotkit"
56+
printf '{"info":{"version":"%s"},"releases":{"%s":[]}}' "$1" "$1" > "$WWW/pypi/copilotkit/json"
57+
}
58+
59+
# Serve a fuller PyPI-shaped response: info.version + a releases dict whose keys
60+
# are the version strings. $1 = info.version, remaining args = release keys.
61+
serve_published_with_releases() {
62+
mkdir -p "$WWW/pypi/copilotkit"
63+
local info="$1"; shift
64+
local rels="" k
65+
for k in "$@"; do
66+
[ -z "$rels" ] && rels="\"$k\":[]" || rels="$rels,\"$k\":[]"
67+
done
68+
printf '{"info":{"version":"%s"},"releases":{%s}}' "$info" "$rels" > "$WWW/pypi/copilotkit/json"
69+
}
4770

4871
run() {
4972
PYPROJECT_PATH="$TMP/pkg/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
50-
"$SCRIPT" 2>/dev/null | tail -n1
73+
"$SCRIPT" 2>"$STDERR_LOG" | tail -n1
74+
}
75+
fail() {
76+
echo "FAIL: $1" >&2
77+
if [ -s "$STDERR_LOG" ]; then
78+
echo "--- captured stderr ---" >&2
79+
cat "$STDERR_LOG" >&2
80+
echo "--- end stderr ---" >&2
81+
fi
82+
exit 1
5183
}
52-
fail() { echo "FAIL: $1" >&2; exit 1; }
5384

5485
# Case A: published == local (0.2.0) -> should_publish=false (no-op)
86+
# Also assert GITHUB_OUTPUT emission: the script must append should_publish=,
87+
# name=, and version= lines when GITHUB_OUTPUT is set.
5588
rm -rf "$WWW"; serve_published "0.2.0"; start_server
56-
OUT="$(run)"; echo "A: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "no-op: got '$OUT'"
89+
GHO="$TMP/gho.txt"; : > "$GHO"
90+
OUT="$(GITHUB_OUTPUT="$GHO" PYPROJECT_PATH="$TMP/pkg/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
91+
"$SCRIPT" 2>"$STDERR_LOG" | tail -n1)"
92+
echo "A: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "no-op: got '$OUT'"
93+
grep -q '^should_publish=false$' "$GHO" || fail "GITHUB_OUTPUT missing should_publish=false (got: $(cat "$GHO"))"
94+
grep -q '^name=copilotkit$' "$GHO" || fail "GITHUB_OUTPUT missing name=copilotkit (got: $(cat "$GHO"))"
95+
grep -q '^version=0.2.0$' "$GHO" || fail "GITHUB_OUTPUT missing version=0.2.0 (got: $(cat "$GHO"))"
5796
stop_server
5897

5998
# Case B: published < local (0.1.91 < 0.2.0) -> should_publish=true (exactly one pkg)
@@ -66,4 +105,30 @@ rm -rf "$WWW"; mkdir -p "$WWW"; start_server
66105
OUT="$(run)"; echo "C: $OUT"; [ "$OUT" = "true copilotkit 0.2.0" ] || fail "new-pkg: got '$OUT'"
67106
stop_server
68107

108+
# Case D: info.version is LOWER than the true max in releases. PyPI's info.version
109+
# is the LATEST-UPLOADED, not the highest — an out-of-order patch upload to an
110+
# old line can produce this state. The script must compute the max over the
111+
# numeric-parseable releases keys, not trust info.version.
112+
# releases = {0.1.0, 0.2.0}, info.version=0.1.0, local=0.2.0 -> 0.2.0==0.2.0 -> false.
113+
rm -rf "$WWW"; serve_published_with_releases "0.1.0" "0.1.0" "0.2.0"; start_server
114+
OUT="$(run)"; echo "D: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "max-over-releases: got '$OUT'"
115+
stop_server
116+
117+
# Case E: releases contains a non-numeric prerelease key alongside numeric. The
118+
# script must ignore non-numeric published keys (not abort on them) and compare
119+
# against the numeric max. info.version is the prerelease (rc1); local is 0.2.1.
120+
# numeric max published = 0.2.0 < 0.2.1 -> should_publish=true.
121+
rm -rf "$WWW"
122+
mkdir -p "$TMP/pkg2"
123+
cat > "$TMP/pkg2/pyproject.toml" <<'TOML'
124+
[tool.poetry]
125+
name = "copilotkit"
126+
version = "0.2.1"
127+
TOML
128+
serve_published_with_releases "0.2.1rc1" "0.2.0" "0.2.1rc1"; start_server
129+
OUT="$(PYPROJECT_PATH="$TMP/pkg2/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
130+
"$SCRIPT" 2>"$STDERR_LOG" | tail -n1)"
131+
echo "E: $OUT"; [ "$OUT" = "true copilotkit 0.2.1" ] || fail "non-numeric-released-ignored: got '$OUT'"
132+
stop_server
133+
69134
echo "ALL PASS"

scripts/release/detect-py-version-changes.sh

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,36 @@ echo "Local: ${NAME}==${VERSION}" >&2
3030

3131
# Fetch published version, distinguishing 404 (new package) from other failures.
3232
RESP="$(mktemp)"; trap 'rm -f "$RESP"' EXIT
33-
CODE="$(curl -sS -o "$RESP" -w '%{http_code}' "${PYPI_BASE_URL}/pypi/${NAME}/json" 2>/dev/null || echo "000")"
33+
CODE="$(curl -sS --max-time 30 --retry 3 --retry-connrefused -o "$RESP" -w '%{http_code}' "${PYPI_BASE_URL}/pypi/${NAME}/json" 2>/dev/null || echo "000")"
3434
case "$CODE" in
3535
200)
36-
PUBLISHED="$(python3 -c 'import sys,json; print(json.load(open(sys.argv[1]))["info"]["version"])' "$RESP")" \
37-
|| { echo "ERROR: bad JSON from PyPI" >&2; exit 1; }
38-
echo "Published: ${NAME}==${PUBLISHED}" >&2 ;;
36+
# Compute the MAX numeric-parseable version from the `releases` dict (the
37+
# complete set of released versions). `info.version` is the LATEST-UPLOADED,
38+
# not necessarily the highest — out-of-order patch uploads to an old line
39+
# can produce info.version < max(releases). Non-numeric keys (prereleases
40+
# like "0.2.0rc1", dev/post tags) are filtered out, not aborted on. If no
41+
# numeric keys exist, treat as empty (same as 404 -> NEW).
42+
PUBLISHED="$(python3 - "$RESP" <<'PY'
43+
import sys, json, re
44+
with open(sys.argv[1]) as f:
45+
data = json.load(f)
46+
releases = data.get("releases") or {}
47+
numeric = []
48+
for k in releases.keys():
49+
if re.fullmatch(r"\d+(\.\d+)*", k):
50+
numeric.append(k)
51+
if not numeric:
52+
print("")
53+
else:
54+
best = max(numeric, key=lambda v: tuple(int(x) for x in v.split(".")))
55+
print(best)
56+
PY
57+
)" || { echo "ERROR: bad JSON from PyPI" >&2; exit 1; }
58+
if [ -z "$PUBLISHED" ]; then
59+
echo "Published: ${NAME} has no numeric releases — treating as NEW" >&2
60+
else
61+
echo "Published: ${NAME}==${PUBLISHED}" >&2
62+
fi ;;
3963
404)
4064
PUBLISHED=""; echo "Not found on PyPI — treating as NEW" >&2 ;;
4165
*)
@@ -46,14 +70,16 @@ if [ -z "$PUBLISHED" ]; then
4670
SHOULD_PUBLISH="true"
4771
else
4872
# Plain X.Y.Z numeric-tuple comparison (no third-party deps). The stable lane
49-
# only ships dotted-numeric versions; refuse anything non-numeric loudly.
73+
# only ships dotted-numeric LOCAL versions; refuse non-numeric LOCAL loudly.
74+
# PUBLISHED is already guaranteed numeric (filtered above when computing max).
5075
SHOULD_PUBLISH="$(python3 - "$VERSION" "$PUBLISHED" <<'PY'
5176
import sys, re
52-
def parse(v):
77+
def parse_local(v):
5378
if not re.fullmatch(r"\d+(\.\d+)*", v):
54-
sys.exit(f"non-numeric version not supported on stable lane: {v!r}")
79+
sys.exit(f"non-numeric local version not supported on stable lane: {v!r}")
5580
return tuple(int(x) for x in v.split("."))
56-
local, pub = parse(sys.argv[1]), parse(sys.argv[2])
81+
local = parse_local(sys.argv[1])
82+
pub = tuple(int(x) for x in sys.argv[2].split("."))
5783
print("true" if local > pub else "false")
5884
PY
5985
)" || exit 1

0 commit comments

Comments
 (0)