1010every recipe, so a subsequent 'conan install --build=missing' resolves recipes
1111from the local cache and builds whatever the remote has no binaries for. CI
1212uses 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
1520from __future__ import annotations
1621
1722import argparse
23+ import json
1824import platform
1925import re
2026import subprocess
2733
2834SUPPORTED_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
3142def 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+
119172def 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]):
193252def 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 ("\n Upload 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"\n Uploaded { 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"\n built: { len (built )} skipped: { len (skipped )} "
341+ f"\n built: { 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 ("\n Nothing was built; skipping upload ." , flush = True )
380+ print ("\n Nothing to upload; every built recipe was already on the remote ." , flush = True )
309381 else :
310382 upload_all (args , built )
311383 print ("\n All done." , flush = True )
0 commit comments