1919log = logging .getLogger ("build-in-container" )
2020
2121IMAGE_REGISTRY = "ghcr.io/cfengine"
22+ CONFIG_PATH = Path (__file__ ).resolve ().parent / "platforms.json"
2223
2324
2425@functools .cache
2526def get_config ():
2627 """Load and cache platform configuration from platforms.json."""
27- config_path = Path (__file__ ).resolve ().parent / "platforms.json"
28- return json .loads (config_path .read_text ())
28+ return json .loads (CONFIG_PATH .read_text ())
2929
3030
3131def detect_source_dir ():
@@ -185,8 +185,60 @@ def update_platform_versions(platform_name=None):
185185 config [name ]["image_version" ] = latest
186186 log .info (f"{ name } : { old } -> { latest } " )
187187
188- config_path = Path (__file__ ).resolve ().parent / "platforms.json"
189- config_path .write_text (json .dumps (config , indent = 2 ) + "\n " )
188+ CONFIG_PATH .write_text (json .dumps (config , indent = 2 ) + "\n " )
189+
190+
191+ def latest_base_image_digest (base_image ):
192+ """Fetch current manifest digest from Docker Hub for a base image."""
193+ # Docker Hub's v2 API path requires a namespace. Official images (ubuntu,
194+ # debian, ...) live under "library/".
195+ repo , tag = base_image .rsplit (":" , 1 )
196+ repo = f"library/{ repo } "
197+
198+ # The v2 API requires a bearer token even for anonymous public pulls.
199+ token_url = (
200+ "https://auth.docker.io/token"
201+ f"?service=registry.docker.io&scope=repository:{ repo } :pull"
202+ )
203+ token = json .loads (urllib .request .urlopen (token_url ).read ())["token" ]
204+
205+ # Accept only the OCI multi-arch index format: this gives the fat manifest
206+ # digest (what `docker pull` pins to) rather than an arch-specific one.
207+ # Docker Hub official images are all published as OCI indexes today; if an
208+ # image is ever served in the older Docker manifest.list.v2 format instead,
209+ # the registry will reject the request with 406.
210+ manifest_url = f"https://registry-1.docker.io/v2/{ repo } /manifests/{ tag } "
211+ accept = "application/vnd.oci.image.index.v1+json"
212+
213+ # HEAD skips the manifest body; the digest comes back in a response header.
214+ req = urllib .request .Request (
215+ manifest_url ,
216+ headers = {"Authorization" : f"Bearer { token } " , "Accept" : accept },
217+ method = "HEAD" ,
218+ )
219+ with urllib .request .urlopen (req ) as resp :
220+ return resp .headers .get ("Docker-Content-Digest" )
221+
222+
223+ def update_base_image_shas (platform_name = None ):
224+ """Update base_image_sha in platforms.json to the latest Docker Hub digest."""
225+ config = get_config ()
226+
227+ platforms = [platform_name ] if platform_name else list (config .keys ())
228+ for name in platforms :
229+ base_image = config [name ]["base_image" ]
230+ latest = latest_base_image_digest (base_image )
231+ if latest is None :
232+ log .warning (f"No digest returned for { base_image } , skipping." )
233+ continue
234+ old = config [name ]["base_image_sha" ]
235+ if old == latest :
236+ log .info (f"{ name } : { base_image } already at { latest } " )
237+ else :
238+ config [name ]["base_image_sha" ] = latest
239+ log .info (f"{ name } : { base_image } { old } -> { latest } " )
240+
241+ CONFIG_PATH .write_text (json .dumps (config , indent = 2 ) + "\n " )
190242
191243
192244def run_container (args , image_tag , source_dir , script_dir ):
@@ -310,6 +362,12 @@ def parse_args():
310362 action = "store_true" ,
311363 help = "Fetch latest image version from registry and update platforms.json" ,
312364 )
365+ parser .add_argument (
366+ "--update-sha" ,
367+ dest = "update_sha" ,
368+ action = "store_true" ,
369+ help = "Fetch latest base image digest from Docker Hub and update platforms.json" ,
370+ )
313371 parser .add_argument (
314372 "--shell" ,
315373 action = "store_true" ,
@@ -332,8 +390,8 @@ def parse_args():
332390 print (f" { name :15s} ({ config ['base_image' ]} )" )
333391 sys .exit (0 )
334392
335- if args .update :
336- # --platform is optional for --update ; updates all if omitted
393+ if args .update or args . update_sha :
394+ # --platform is optional for these modes ; updates all if omitted
337395 return args
338396
339397 # --platform is always required (except --list-platforms/--update handled above)
@@ -367,6 +425,10 @@ def main():
367425 update_platform_versions (args .platform )
368426 return
369427
428+ if args .update_sha :
429+ update_base_image_shas (args .platform )
430+ return
431+
370432 # Detect source directory
371433 if args .source_dir :
372434 source_dir = Path (args .source_dir ).resolve ()
0 commit comments