Skip to content

Commit ba4dfb7

Browse files
committed
build: skip publishing conan recipes already present on the remote
1 parent 08f4827 commit ba4dfb7

1 file changed

Lines changed: 85 additions & 13 deletions

File tree

ThirdParty/ConanRecipes/build_recipes.py

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@
1010
every recipe, so a subsequent 'conan install --build=missing' resolves recipes
1111
from the local cache and builds whatever the remote has no binaries for. CI
1212
uses this to validate recipe changes together with the engine build.
13+
14+
Before building each recipe the script exports it and runs 'conan graph info' to
15+
see whether a matching binary already exists on the remote. If so the recipe is
16+
skipped entirely -- no download, no rebuild, and no no-op re-upload. Pass
17+
--no-skip-existing to force every supported recipe through 'conan create'.
1318
"""
1419

1520
from __future__ import annotations
1621

1722
import argparse
23+
import json
1824
import platform
1925
import re
2026
import subprocess
@@ -27,6 +33,11 @@
2733

2834
SUPPORTED_PLATFORMS = ("Windows-x86_64", "Macos-armv8")
2935

36+
# 'conan graph info' binary states that mean a matching binary already exists (in the local cache or on
37+
# a remote) and so needs neither building nor downloading-to-rebuild. We skip 'conan create' for these
38+
# so an unchanged recipe is never pulled from the remote just to be re-uploaded as a no-op.
39+
ALREADY_AVAILABLE_BINARY_STATES = {"Cache", "Download", "Update"}
40+
3041

3142
def current_platform() -> str:
3243
system = platform.system()
@@ -116,6 +127,48 @@ def run(cmd: list[str], cwd: Path | None = None, redact: set[str] | None = None)
116127
return subprocess.run(cmd, cwd=str(cwd) if cwd else None).returncode
117128

118129

130+
def run_capture(cmd: list[str], cwd: Path | None = None) -> tuple[int, str]:
131+
print(f"\n$ {' '.join(cmd)}", flush=True)
132+
proc = subprocess.run(cmd, cwd=str(cwd) if cwd else None, stdout=subprocess.PIPE, text=True)
133+
return proc.returncode, proc.stdout
134+
135+
136+
def find_binary_state(graph_json: str, reference: str) -> str | None:
137+
try:
138+
nodes = json.loads(graph_json).get("graph", {}).get("nodes", {})
139+
except (json.JSONDecodeError, AttributeError):
140+
return None
141+
for node in nodes.values():
142+
ref = node.get("ref") or ""
143+
if ref.split("#", 1)[0] == reference:
144+
return node.get("binary")
145+
return None
146+
147+
148+
def remote_already_has(args: argparse.Namespace, recipe: Recipe, create_extra: list[str]) -> bool:
149+
# Export so the local recipe revision (a hash of the recipe's contents) lands in the cache; for an
150+
# unchanged recipe it equals the revision already on the remote, letting 'conan graph info' resolve
151+
# the matching binary. Any uncertainty -- export/query failure, unparseable output, an unexpected
152+
# state -- falls through to a normal 'conan create'; the pre-check only ever short-circuits a sure
153+
# hit, never a guess.
154+
export = [args.conan, "export", f"{recipe.dir_name}/conanfile.py", "--version", recipe.version]
155+
if run(export, cwd=args.recipes_root) != 0:
156+
return False
157+
158+
info = [args.conan, "graph", "info", f"--requires={recipe.reference}", "--format=json"]
159+
if args.remote:
160+
info += ["-r", args.remote]
161+
info += create_extra
162+
code, out = run_capture(info, cwd=args.recipes_root)
163+
if code != 0:
164+
return False
165+
166+
state = find_binary_state(out, recipe.reference)
167+
if state:
168+
print(f" remote binary state: {state}", flush=True)
169+
return state in ALREADY_AVAILABLE_BINARY_STATES
170+
171+
119172
def parse_args() -> argparse.Namespace:
120173
parser = argparse.ArgumentParser(
121174
description="Build and optionally upload all Conan recipes.",
@@ -155,6 +208,12 @@ def parse_args() -> argparse.Namespace:
155208
action="store_true",
156209
help="only 'conan export' every version of every recipe; no build, no platform filter",
157210
)
211+
parser.add_argument(
212+
"--skip-existing",
213+
action=argparse.BooleanOptionalAction,
214+
default=True,
215+
help="before building, query the remote and skip any recipe whose binary is already published",
216+
)
158217

159218
upload = parser.add_argument_group("upload")
160219
upload.add_argument(
@@ -193,6 +252,7 @@ def export_all(args: argparse.Namespace, recipes: list[Recipe]):
193252
def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str):
194253
built: list[Recipe] = []
195254
skipped: list[tuple[Recipe, str]] = []
255+
present: list[Recipe] = []
196256

197257
create_extra: list[str] = []
198258
for profile in args.profile:
@@ -202,14 +262,19 @@ def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str):
202262
for index, recipe in enumerate(recipes, start=1):
203263
header = f"[{index}/{len(recipes)}] {recipe.name}"
204264
if recipe.version is None:
205-
fail(args, built, skipped, recipe,
265+
fail(args, built, skipped, present, recipe,
206266
f"could not determine latest version from {recipe.conandata}")
207267
if not recipe.supports(host):
208268
reason = f"not built on {host} (platforms: {recipe.platforms or 'none'})"
209269
print(f"\n=== {header} -- SKIP: {reason} ===", flush=True)
210270
skipped.append((recipe, reason))
211271
continue
212272

273+
if args.skip_existing and remote_already_has(args, recipe, create_extra):
274+
print(f"\n=== {header} -- {recipe.reference} already on remote, skipping ===", flush=True)
275+
present.append(recipe)
276+
continue
277+
213278
print(f"\n=== {header} -- building {recipe.reference} ===", flush=True)
214279
start = time.time()
215280
cmd = [
@@ -220,25 +285,23 @@ def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str):
220285
code = run(cmd, cwd=args.recipes_root)
221286
elapsed = time.time() - start
222287
if code != 0:
223-
fail(args, built, skipped, recipe,
288+
fail(args, built, skipped, present, recipe,
224289
f"'conan create' exited with code {code} after {elapsed:.0f}s")
225290
print(f"--- {recipe.reference} built in {elapsed:.0f}s ---", flush=True)
226291
built.append(recipe)
227292

228-
return built, skipped
293+
return built, skipped, present
229294

230295

231-
def fail(args, built, skipped, recipe: Recipe, message: str):
296+
def fail(args, built, skipped, present, recipe: Recipe, message: str):
232297
print(f"\n!!! BUILD FAILED: {recipe.name} -- {message}", file=sys.stderr, flush=True)
233-
print_summary(built, skipped, failed=recipe)
298+
print_summary(built, skipped, present, failed=recipe)
234299
if args.upload:
235300
print("\nUpload skipped: not all recipes built successfully.", flush=True)
236301
sys.exit(1)
237302

238303

239-
def upload_all(args: argparse.Namespace, built: list[Recipe]):
240-
print("\n=== uploading packages ===", flush=True)
241-
304+
def configure_remote(args: argparse.Namespace):
242305
if args.remote_url:
243306
if run([args.conan, "remote", "add", "--force", args.remote, args.remote_url]) != 0:
244307
sys.exit("error: failed to register remote")
@@ -252,25 +315,30 @@ def upload_all(args: argparse.Namespace, built: list[Recipe]):
252315
if run(login, redact=redact) != 0:
253316
sys.exit("error: failed to log in to remote")
254317

318+
319+
def upload_all(args: argparse.Namespace, built: list[Recipe]):
320+
print("\n=== uploading packages ===", flush=True)
255321
for recipe in built:
256322
print(f"\n--- uploading {recipe.reference} ---", flush=True)
257323
if run([args.conan, "upload", recipe.reference, "-r", args.remote, "--confirm"]) != 0:
258324
sys.exit(f"error: failed to upload {recipe.reference}")
259325
print(f"\nUploaded {len(built)} package(s) to '{args.remote}'.", flush=True)
260326

261327

262-
def print_summary(built, skipped, failed: Recipe | None = None):
328+
def print_summary(built, skipped, present, failed: Recipe | None = None):
263329
print("\n" + "=" * 60, flush=True)
264330
print("SUMMARY", flush=True)
265331
print("=" * 60, flush=True)
266332
for recipe in built:
267333
print(f" [OK] {recipe.reference}", flush=True)
334+
for recipe in present:
335+
print(f" [REMOTE] {recipe.reference} (already published)", flush=True)
268336
for recipe, reason in skipped:
269337
print(f" [SKIP] {recipe.name} ({reason})", flush=True)
270338
if failed is not None:
271339
print(f" [FAILED] {failed.reference}", flush=True)
272340
print(
273-
f"\nbuilt: {len(built)} skipped: {len(skipped)}"
341+
f"\nbuilt: {len(built)} on-remote: {len(present)} skipped: {len(skipped)}"
274342
+ (" failed: 1" if failed is not None else ""),
275343
flush=True,
276344
)
@@ -300,12 +368,16 @@ def main():
300368
recipes = order_by_dependencies(recipes)
301369
print("Build order: " + ", ".join(r.name for r in recipes), flush=True)
302370

303-
built, skipped = build_all(args, recipes, host)
304-
print_summary(built, skipped)
371+
# Log in before building so the per-recipe pre-check can query the remote for existing binaries.
372+
if args.upload:
373+
configure_remote(args)
374+
375+
built, skipped, present = build_all(args, recipes, host)
376+
print_summary(built, skipped, present)
305377

306378
if args.upload:
307379
if not built:
308-
print("\nNothing was built; skipping upload.", flush=True)
380+
print("\nNothing to upload; every built recipe was already on the remote.", flush=True)
309381
else:
310382
upload_all(args, built)
311383
print("\nAll done.", flush=True)

0 commit comments

Comments
 (0)