Skip to content

Commit d7b57bf

Browse files
authored
Improve S3 promotion: ordering, R2 grep fixes, and a per-target promoter (#8071)
## Summary Three coordinated changes to the release promotion path. ### 1. Split `aws_promote` and reorder `s3_to_s3.sh` `release/promote/common_utils.sh` now splits `aws_promote` into two functions: - `aws_promote` - server-side S3 copy only (fast). - `aws_set_checksums` - the `manage_v2.py --set-checksum` loop, which downloads every wheel from S3 to compute SHA256 (slow). `release/promote/s3_to_s3.sh` now calls them in the new order: **S3 copy → R2 upload → S3 checksum recompute**. R2 previously waited on the per-wheel SHA256 download loop because it ran *inside* the old `aws_promote`; the new ordering unblocks R2 immediately after the server-side S3 copy. `aws_set_checksums` also skips automatically when the destination prefix is not `whl` / `whl/test` (e.g. `libtorch`), since `manage_v2.py --set-checksum` raises `ValueError: Prefix must be whl or whl/test` for anything else. ### 2. Fix `r2_promote` grep bugs for glob package names and suffixes `PACKAGE_INCLUDE_SUFFIX` (e.g. `*manylinux*`) and `PACKAGE_NAME` (e.g. `libtorch-*`) are AWS S3 *globs* in `aws_promote`'s `--include` flag. The old `r2_promote` passed them straight to `grep` where `*` is the regex zero-or-more operator, so `triton-3.7.0*manylinux*` and `libtorch-*-2.12.0*` matched nothing and R2 silently found 0 files. Both now have their `*` expanded to `.*` before being plugged into `grep -E`, so the same expression that selects files for `aws_promote` also selects them for `r2_promote`. ### 3. New `--promote-from` flag in `update_dependencies.py` `s3_management/update_dependencies.py` gains a `promote_target` function and `--promote-from` CLI flag for promoting per-package wheel artifacts across prefixes (e.g. `whl/test/cu132 -> whl/cu132`). For each entry in `PACKAGES_PER_PROJECT`, it copies `.whl`, `.whl.metadata`, `.tar.gz`, and `.tgz` files that exist at the source, matched by distribution name (PEP 503 normalized), and skips any that already exist at the destination. Also accepts `xpu` as a valid target name. Example usage: ```bash python3 s3_management/update_dependencies.py \ --promote-from whl/test \ --prefix whl \ --target cu132 \ --dry-run ``` ## Test plan - [x] Verified the new `promote_target` finds and copies the right files for `cu132` in dry-run mode. - [x] Verified `r2_promote` now matches both `*manylinux*`-filtered wheels and `libtorch-*` globs after the regex fix. - [x] Verified `aws_set_checksums` skips with a clean log line for the libtorch promotion (prefix `libtorch` instead of `whl`). - [x] Local syntax checks (`bash -n`, `python3 -c "ast.parse(...)"`) pass for all three files. - [ ] End-to-end run with `DRY_RUN=disabled` on a real release. Authored with Claude Code.
1 parent 313bb3d commit d7b57bf

3 files changed

Lines changed: 241 additions & 24 deletions

File tree

release/promote/common_utils.sh

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -58,29 +58,50 @@ aws_promote() {
5858
)
5959
# ^ We grep for package_name-.*pytorch_version to avoid any situations where domain libraries have
6060
# the same version on our S3 buckets
61+
}
6162

62-
# After copying, explicitly set SHA256 checksums for wheels that don't have them
63-
# This ensures checksums are preserved even if --metadata-directive COPY fails to copy them
64-
if [[ $DRY_RUN = "disabled" ]]; then
65-
echo "+ Setting SHA256 checksums for copied wheels..."
66-
dest_prefix="${PYTORCH_S3_TO#s3://pytorch/}"
67-
dest_prefix="${dest_prefix%/}"
68-
69-
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
70-
manage_v2_script="${script_dir}/../../s3_management/manage_v2.py"
71-
72-
if [[ -f "${manage_v2_script}" ]]; then
73-
echo "+ Running: python ${manage_v2_script} ${dest_prefix} --set-checksum --package-name ${package_name} --package-version ${pytorch_version}"
74-
python "${manage_v2_script}" "${dest_prefix}" \
75-
--set-checksum \
76-
--package-name "${package_name}" \
77-
--package-version "${pytorch_version}" || {
78-
echo "- WARNING: Failed to set SHA256 checksums, but copy succeeded"
79-
}
80-
else
81-
echo "- WARNING: manage_v2.py not found at ${manage_v2_script}, skipping checksum computation"
82-
fi
63+
aws_set_checksums() {
64+
# Re-derive SHA256 checksum metadata on the S3 destination wheels.
65+
# Runs as the final step of promotion so faster operations (S3 copy, R2
66+
# upload) are not blocked waiting on this download-heavy loop.
67+
package_name=$1
68+
pytorch_version=$(get_pytorch_version)
69+
DRY_RUN=${DRY_RUN:-enabled}
70+
71+
if [[ $DRY_RUN != "disabled" ]]; then
72+
echo "+ DRY RUN: skipping SHA256 recomputation for ${package_name}"
73+
return 0
74+
fi
75+
76+
echo "=-=-=-= Setting SHA256 checksums for ${package_name} v${pytorch_version} on S3 =-=-=-="
77+
dest_prefix="${PYTORCH_S3_TO#s3://pytorch/}"
78+
dest_prefix="${dest_prefix%/}"
79+
80+
# manage_v2.py --set-checksum only supports whl and whl/test prefixes
81+
# (it raises ValueError on anything else). Skip for libtorch/etc.
82+
case "${dest_prefix}" in
83+
whl|whl/test) ;;
84+
*)
85+
echo "+ Skipping SHA256 recomputation: dest prefix '${dest_prefix}' is not whl/whl-test; manage_v2.py only supports those."
86+
return 0
87+
;;
88+
esac
89+
90+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
91+
manage_v2_script="${script_dir}/../../s3_management/manage_v2.py"
92+
93+
if [[ ! -f "${manage_v2_script}" ]]; then
94+
echo "- WARNING: manage_v2.py not found at ${manage_v2_script}, skipping checksum computation"
95+
return 0
8396
fi
97+
98+
echo "+ Running: python ${manage_v2_script} ${dest_prefix} --set-checksum --package-name ${package_name} --package-version ${pytorch_version}"
99+
python "${manage_v2_script}" "${dest_prefix}" \
100+
--set-checksum \
101+
--package-name "${package_name}" \
102+
--package-version "${pytorch_version}" || {
103+
echo "- WARNING: Failed to set SHA256 checksums, but copy succeeded"
104+
}
84105
}
85106

86107
r2_promote() {
@@ -105,11 +126,19 @@ r2_promote() {
105126
echo "=-=-=-= Promoting ${package_name} v${pytorch_version} to R2 =-=-=-="
106127
echo "+ R2 destination: ${r2_dest}"
107128

129+
# PACKAGE_NAME and PACKAGE_INCLUDE_SUFFIX are consumed as AWS S3 globs by
130+
# aws_promote (e.g. libtorch-*, *manylinux*). Convert their '*' wildcards
131+
# into '.*' so the same expressions work as a grep -E regex here.
132+
local pkg_regex="${package_name//\*/.*}"
133+
local include_glob="${PACKAGE_INCLUDE_SUFFIX:-*}"
134+
local include_regex="${include_glob//\*/.*}"
135+
local match_pattern="${pkg_regex}-${pytorch_version}${include_regex}"
136+
108137
if [[ $DRY_RUN = "enabled" ]]; then
109138
echo "+ DRY RUN: Would copy matching files from ${PYTORCH_S3_FROM} to R2 ${r2_dest}"
110139
# List what would be copied
111140
${AWS} s3 ls "${PYTORCH_S3_FROM/\/$//}/" --recursive \
112-
| grep "${package_name}-${pytorch_version}${PACKAGE_INCLUDE_SUFFIX:-}" || true
141+
| grep -E "${match_pattern}" || true
113142
return 0
114143
fi
115144

@@ -129,7 +158,7 @@ r2_promote() {
129158
local s3_from_path="${PYTORCH_S3_FROM/\/$//}"
130159
local file_list="${tmp_dir}/file_list.txt"
131160
${AWS} s3 ls "${s3_from_path}/" --recursive \
132-
| grep "${package_name}-${pytorch_version}${PACKAGE_INCLUDE_SUFFIX:-}" \
161+
| grep -E "${match_pattern}" \
133162
| awk '{print $NF}' > "${file_list}" || true
134163

135164
local total_files

release/promote/s3_to_s3.sh

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,13 @@ else
2525
echo "+ R2_ONLY=true, skipping S3-to-S3 promotion"
2626
fi
2727

28-
# Also promote to R2 (Cloudflare) if credentials are available
28+
# Promote to R2 (Cloudflare) before the slow SHA256 recomputation step so R2
29+
# is not blocked waiting on per-wheel downloads on the S3 destination.
2930
r2_promote "${PACKAGE_NAME}"
31+
32+
# Finally, recompute SHA256 checksum metadata on the S3 destination wheels.
33+
# This is the slowest step (downloads every wheel from S3) and runs last so
34+
# it does not delay the R2 upload above.
35+
if [[ "${R2_ONLY}" != "true" ]]; then
36+
aws_set_checksums "${PACKAGE_NAME}"
37+
fi

s3_management/update_dependencies.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
VALID_TARGET_PATTERNS = [
1515
r"^cu[0-9]+$", # CUDA: cu118, cu121, cu126, cu128, cu129, cu130, cuXYZ
1616
r"^rocm[0-9]+\.[0-9]+$", # ROCm: rocm5.7, rocm6.0, rocm6.4, rocm7.1, rocm7.2
17+
r"^xpu$", # Intel XPU
1718
]
1819

1920
# Cloudflare R2 configuration for writing indexes
@@ -1082,6 +1083,162 @@ def copy_target(
10821083
return True
10831084

10841085

1086+
def promote_target(
1087+
source_prefix: str,
1088+
target_prefix: str,
1089+
target: str,
1090+
*,
1091+
dry_run: bool = False,
1092+
) -> bool:
1093+
"""
1094+
Promote a target across prefixes by copying packages from PACKAGES_PER_PROJECT.
1095+
1096+
Scan files directly in {source_prefix}/{target}/ (not in subdirectories)
1097+
and copy any wheel/tarball whose distribution name belongs to
1098+
PACKAGES_PER_PROJECT into {target_prefix}/{target}/. PEP 658 metadata
1099+
sidecars (.whl.metadata) accompanying matched wheels are also copied.
1100+
Used for promotion flows such as whl/test/cu132 -> whl/cu132, where the
1101+
destination target directory does not yet exist. Only binary artifacts
1102+
and their metadata are copied (.whl, .whl.metadata, .tar.gz, .tgz);
1103+
index.html files and per-package subdirectories are intentionally skipped
1104+
because the destination index is regenerated by other tooling.
1105+
1106+
Args:
1107+
source_prefix: Source prefix (e.g., "whl/test")
1108+
target_prefix: Destination prefix (e.g., "whl")
1109+
target: The target name (e.g., "cu132")
1110+
dry_run: If True, don't actually copy anything
1111+
1112+
Returns:
1113+
True if at least one object was (or would be) copied, False otherwise.
1114+
"""
1115+
if not is_valid_target(target):
1116+
print(f"ERROR: Invalid target name '{target}'")
1117+
print(f"Valid patterns: {VALID_TARGET_PATTERNS}")
1118+
return False
1119+
1120+
source_target_path = f"{source_prefix}/{target}"
1121+
dest_target_path = f"{target_prefix}/{target}"
1122+
1123+
# Check source exists
1124+
if not dry_run and not target_exists(source_prefix, target):
1125+
print(f"ERROR: Source '{source_target_path}' does not exist in S3.")
1126+
return False
1127+
1128+
# Warn if destination already exists but continue anyway
1129+
if not dry_run and target_exists(target_prefix, target):
1130+
print(
1131+
f"WARNING: Destination '{dest_target_path}' already exists, "
1132+
"will copy into it."
1133+
)
1134+
1135+
print(
1136+
f"{'[DRY RUN] ' if dry_run else ''}"
1137+
f"Promoting {source_target_path} -> {dest_target_path}"
1138+
)
1139+
1140+
copied_count = 0
1141+
skipped_count = 0
1142+
existing_count = 0
1143+
artifact_suffixes = (".whl", ".whl.metadata", ".tar.gz", ".tgz")
1144+
matched_packages: set[str] = set()
1145+
1146+
def s3_object_exists(key: str) -> bool:
1147+
try:
1148+
CLIENT.head_object(Bucket="pytorch", Key=key)
1149+
return True
1150+
except CLIENT.exceptions.ClientError as exc:
1151+
if exc.response["Error"]["Code"] in ("404", "NoSuchKey"):
1152+
return False
1153+
raise
1154+
1155+
# Normalized lookup set: lowercase, "_" -> "-" (PEP 503 / wheel filename convention)
1156+
allowed_packages = {name.lower().replace("_", "-") for name in PACKAGES_PER_PROJECT}
1157+
1158+
def package_in_allowlist(filename: str) -> str:
1159+
"""Return the canonical package name if filename belongs to an allowed
1160+
package, else empty string. Wheel and sdist filenames are
1161+
'<distribution>-<version>...' with the distribution normalized to use
1162+
underscores; split on the first '-' to recover it."""
1163+
dist, sep, _ = filename.partition("-")
1164+
if not sep:
1165+
return ""
1166+
canonical = dist.lower().replace("_", "-")
1167+
return canonical if canonical in allowed_packages else ""
1168+
1169+
paginator = CLIENT.get_paginator("list_objects_v2")
1170+
# Delimiter='/' restricts the listing to objects directly under the prefix
1171+
# (no recursion into per-package subdirectories).
1172+
for page in paginator.paginate(
1173+
Bucket="pytorch",
1174+
Prefix=f"{source_target_path}/",
1175+
Delimiter="/",
1176+
):
1177+
for obj in page.get("Contents", []):
1178+
source_key = obj["Key"]
1179+
filename = source_key.rsplit("/", 1)[-1]
1180+
1181+
if not filename.endswith(artifact_suffixes):
1182+
skipped_count += 1
1183+
continue
1184+
1185+
canonical = package_in_allowlist(filename)
1186+
if not canonical:
1187+
skipped_count += 1
1188+
continue
1189+
1190+
matched_packages.add(canonical)
1191+
dest_key = f"{dest_target_path}/{filename}"
1192+
1193+
# Skip if the destination already has this artifact
1194+
if s3_object_exists(dest_key):
1195+
existing_count += 1
1196+
print(f" Skipping (already exists at destination): {dest_key}")
1197+
continue
1198+
1199+
if dry_run:
1200+
print(f" [DRY RUN] Would copy: {source_key} -> {dest_key}")
1201+
else:
1202+
print(f" Copying: {source_key} -> {dest_key}")
1203+
CLIENT.copy_object(
1204+
Bucket="pytorch",
1205+
CopySource={"Bucket": "pytorch", "Key": source_key},
1206+
Key=dest_key,
1207+
ACL="public-read",
1208+
)
1209+
1210+
# Also copy to R2 if configured (cross-service copy)
1211+
if R2_BUCKET:
1212+
print(f" Copying to R2: {source_key} -> {dest_key}")
1213+
response = CLIENT.get_object(Bucket="pytorch", Key=source_key)
1214+
body = response["Body"].read()
1215+
# Set ContentType from the filename rather than trusting the
1216+
# source object's metadata: wheels and tarballs are binary
1217+
# and must not be served as text/html. PEP 658 .whl.metadata
1218+
# sidecars are RFC 822 text.
1219+
if filename.endswith(".whl.metadata"):
1220+
content_type = "text/plain"
1221+
else:
1222+
content_type = "application/octet-stream"
1223+
R2_BUCKET.Object(key=dest_key).put(
1224+
ACL="public-read",
1225+
ContentType=content_type,
1226+
CacheControl="no-cache,no-store,must-revalidate",
1227+
Body=body,
1228+
)
1229+
1230+
copied_count += 1
1231+
1232+
print(
1233+
f"{'[DRY RUN] ' if dry_run else ''}"
1234+
f"Promotion complete: {len(matched_packages)} packages matched, "
1235+
f"{copied_count} artifacts copied "
1236+
f"({existing_count} already existed at destination, "
1237+
f"{skipped_count} files skipped)"
1238+
)
1239+
return copied_count > 0
1240+
1241+
10851242
def create_target(
10861243
prefix: str,
10871244
target: str,
@@ -1188,6 +1345,15 @@ def main() -> None:
11881345
help="Initialize a new target by copying all folders and index.html from an existing source target "
11891346
"(e.g., --init-from cu130 --target cu132 copies whl/nightly/cu130/* -> whl/nightly/cu132/*)",
11901347
)
1348+
parser.add_argument(
1349+
"--promote-from",
1350+
type=str,
1351+
help="Promote a target across prefixes by copying every package in "
1352+
"PACKAGES_PER_PROJECT that exists under <promote-from>/<target>/ into "
1353+
"<prefix>/<target>/ (e.g., --promote-from whl/test --prefix whl "
1354+
"--target cu132 copies whl/test/cu132/<pkg>/ -> whl/cu132/<pkg>/ for "
1355+
"every package listed in PACKAGES_PER_PROJECT).",
1356+
)
11911357
parser.add_argument(
11921358
"--prefix",
11931359
type=str,
@@ -1211,6 +1377,20 @@ def main() -> None:
12111377
)
12121378
return
12131379

1380+
# Handle promote-from mode (promote target across prefixes)
1381+
if args.promote_from:
1382+
if not args.target:
1383+
print("ERROR: --target is required when using --promote-from")
1384+
return
1385+
1386+
promote_target(
1387+
args.promote_from,
1388+
args.prefix,
1389+
args.target,
1390+
dry_run=args.dry_run,
1391+
)
1392+
return
1393+
12141394
# Handle target creation mode
12151395
if args.create_target:
12161396
if not args.target:

0 commit comments

Comments
 (0)