dependabot-docker 0.382.0 → 0.384.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c3c3a9d65ba79f649da1e2078a73d446587e67b3b78ccca293710fd8a08c417
4
- data.tar.gz: d6862a5fd24ceab65a80f42b555d0d2219585a371464b7774c52f5b369dcc3aa
3
+ metadata.gz: 62ae318b274003dd8cb082364cb06233a54d27c993e96ebd598128b198f280e7
4
+ data.tar.gz: 3c2c803bab214190b06d60ffe76c4de7781cc81254403a616220d685f1040347
5
5
  SHA512:
6
- metadata.gz: 73acf20b4b759b7987898616686b1edbb423541b0b7c259c4568f8fd66c4b5c336cb7dc8fdbf83e1e054a458a4a3f0d04c9d6b605180416ba6ccfa5092ca6b59
7
- data.tar.gz: d674d576671aa6d270fc8d80db56c62dc46cb026f38342e3fdc83ede3204a517c7d4960d503cd9c45641d10cb7f10e6076c62488c7a2c53b0db4464facce0a3a
6
+ metadata.gz: 91b741842929d3bb485325f0315a71defae699ea33b16e0c4fe7f605dfc3e3caeb4db99c2751ccb170059d3e7a741d6c1cc77483ff79ac0e52c18d16be6b6141
7
+ data.tar.gz: f3d93ecfb28273c0af1fe959cc830b67121c20ec8010f086865bbcf3191bbff301da0e406b7305a609e946ae6342898a50b815eea776bf782f09512f3dccc585
@@ -60,6 +60,17 @@ module Dependabot
60
60
  T::Array[String]
61
61
  )
62
62
 
63
+ # Media types returned for single-platform (non manifest-list) images.
64
+ # Used to cheaply rule out manifest-list comparison via a HEAD request's
65
+ # negotiated Content-Type, avoiding a full manifest GET for these images.
66
+ SINGLE_PLATFORM_MANIFEST_TYPES = T.let(
67
+ [
68
+ "application/vnd.docker.distribution.manifest.v2+json",
69
+ "application/vnd.oci.image.manifest.v1+json"
70
+ ].freeze,
71
+ T::Array[String]
72
+ )
73
+
63
74
  # Tolerance window for platform timestamp comparison.
64
75
  # Multi-arch CI builds may finish platforms at slightly different times.
65
76
  PLATFORM_TIMESTAMP_TOLERANCE_SECONDS = T.let(3 * 60 * 60, Integer)
@@ -141,9 +152,9 @@ module Dependabot
141
152
  if tag
142
153
  updated_tag = latest_version_from(tag)
143
154
  updated_source[:tag] = updated_tag
144
- updated_source[:digest] = digest_of(updated_tag) if digest || pin_digests?
155
+ updated_source[:digest] = resolved_digest_for(tag, updated_tag, digest) if digest || pin_digests?
145
156
  elsif digest
146
- updated_source[:digest] = digest_of("latest")
157
+ updated_source[:digest] = digest_within_cooldown?("latest") ? digest : digest_of("latest")
147
158
  end
148
159
 
149
160
  req.merge(source: updated_source)
@@ -206,37 +217,51 @@ module Dependabot
206
217
 
207
218
  sig { returns(T::Boolean) }
208
219
  def digest_up_to_date?
209
- digest_requirements.all? do |req|
210
- source = req.fetch(:source)
211
- source_digest = source.fetch(:digest)
212
- source_tag = source[:tag]
213
-
214
- expected_digest =
215
- if source_tag
216
- latest_tag = latest_tag_from(source_tag)
217
-
218
- # When digest-only updates are suppressed and the tag hasn't changed,
219
- # treat the digest as up-to-date to avoid proposing a PR that only
220
- # bumps the digest without a corresponding version change.
221
- # Only apply to comparable (versioned) tags — non-comparable tags like
222
- # "latest" or distro codenames should still get digest updates.
223
- if Dependabot::Experiments.enabled?(:docker_digest_only_update_suppression) &&
224
- Tag.new(source_tag).comparable? &&
225
- latest_tag.name == source_tag
226
- next true
227
- end
228
-
229
- digest_of(latest_tag.name)
230
- else
231
- updated_digest
232
- end
220
+ digest_requirements.all? { |req| digest_requirement_up_to_date?(req) }
221
+ end
222
+
223
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
224
+ def digest_requirement_up_to_date?(req)
225
+ source = req.fetch(:source)
226
+ source_digest = source.fetch(:digest)
227
+ source_tag = source[:tag]
228
+
229
+ if source_tag
230
+ latest_tag = latest_tag_from(source_tag)
231
+ digest_only_refresh = latest_tag.name == source_tag
232
+
233
+ # Digest-only refresh (tag unchanged): respect the cooldown window
234
+ # so a freshly-pushed digest isn't proposed before it matures. This
235
+ # covers both comparable and non-comparable tags (e.g. "alpine"),
236
+ # which the version-tag cooldown (apply_cooldown) never reaches.
237
+ return true if digest_only_refresh && digest_within_cooldown?(source_tag)
233
238
 
234
- # If we can't determine an expected digest (for example if the registry does not return digests)
235
- # assume it's up to date
236
- next true if expected_digest.nil?
239
+ # When digest-only updates are suppressed and the tag hasn't changed,
240
+ # treat the digest as up-to-date to avoid proposing a PR that only
241
+ # bumps the digest without a corresponding version change.
242
+ # Only apply to comparable (versioned) tags — non-comparable tags like
243
+ # "latest" or distro codenames should still get digest updates.
244
+ return true if digest_only_update_suppressed?(source_tag, latest_tag)
237
245
 
238
- source_digest == expected_digest
246
+ expected_digest = digest_of(latest_tag.name)
247
+ else
248
+ digest_only_refresh = true
249
+ # Pure digest pin (no tag): the proposed digest resolves from the
250
+ # "latest" tag, so gate it on the same cooldown window.
251
+ return true if digest_within_cooldown?("latest")
252
+
253
+ expected_digest = updated_digest
239
254
  end
255
+
256
+ # If we can't determine an expected digest (for example if the registry does not return digests)
257
+ # assume it's up to date
258
+ return true if expected_digest.nil?
259
+
260
+ # Multi-arch no-op: a digest-only refresh whose index digest changed only
261
+ # because an unconsumed platform was rebuilt should not open a PR.
262
+ return true if digest_refresh_is_noop?(source, source_digest, expected_digest, digest_only_refresh)
263
+
264
+ source_digest == expected_digest
240
265
  end
241
266
 
242
267
  sig { params(version: String).returns(String) }
@@ -263,6 +288,7 @@ module Dependabot
263
288
  # (which requires a call to the registry for each tag, so can be slow)
264
289
  candidate_tags = comparable_tags_from_registry(version_tag)
265
290
  candidate_tags = remove_version_downgrades(candidate_tags, version_tag)
291
+ candidate_tags = remove_more_precise_tags(candidate_tags, version_tag)
266
292
  candidate_tags = remove_prereleases(candidate_tags, version_tag)
267
293
  candidate_tags = filter_ignored(candidate_tags)
268
294
  candidate_tags = sort_tags(candidate_tags, version_tag)
@@ -285,14 +311,26 @@ module Dependabot
285
311
  candidate_tags.reverse_each do |candidate|
286
312
  selected = select_tag_with_precision(candidate, same_precision_tags, version_tag)
287
313
 
314
+ # Content check (runs regardless of experiments): if the candidate tag
315
+ # resolves to the exact same image contents as the current tag, there
316
+ # is no real update to make. This catches rolling tags (e.g. "9.0")
317
+ # that point to the same image as a pinned tag (e.g. "9.0.11").
318
+ if selected.name != version_tag.name && same_image_contents?(selected, version_tag)
319
+ Dependabot.logger.info(
320
+ "Digest check: #{selected.name} has the same image contents as #{version_tag.name} " \
321
+ "— no update needed, staying on #{version_tag.name}"
322
+ )
323
+ next
324
+ end
325
+
288
326
  if Dependabot::Experiments.enabled?(:docker_created_timestamp_validation) &&
289
327
  selected.name != version_tag.name
290
328
  if validation_attempts >= MAX_PLATFORM_VALIDATION_ATTEMPTS
291
329
  Dependabot.logger.info(
292
330
  "Platform validation: reached max attempts (#{MAX_PLATFORM_VALIDATION_ATTEMPTS}), " \
293
- "accepting #{selected.name} without timestamp check"
331
+ "skipping #{selected.name} continuing to find a validated candidate"
294
332
  )
295
- return selected
333
+ next
296
334
  end
297
335
  validation_attempts += 1
298
336
  end
@@ -433,7 +471,7 @@ module Dependabot
433
471
  published_date = last_modified ? Time.parse(last_modified) : nil
434
472
 
435
473
  Dependabot::Package::PackageRelease.new(
436
- version: Docker::Version.new(tag.name),
474
+ version: release_version_for(tag),
437
475
  released_at: published_date,
438
476
  latest: false,
439
477
  yanked: false,
@@ -562,6 +600,26 @@ module Dependabot
562
600
  end
563
601
  end
564
602
 
603
+ # When the current tag pins only the major or major.minor version (e.g.
604
+ # "9.0"), a candidate that additionally specifies a patch version (e.g.
605
+ # "9.0.17") is not a wanted upgrade: pinning the less precise tag opts into
606
+ # a rolling tag, so the patch should not be specified for them. Only true
607
+ # semver tags (":normal" format) are filtered here — build-number and date
608
+ # based formats have their own comparability rules and are left untouched.
609
+ sig do
610
+ params(
611
+ candidate_tags: T::Array[Dependabot::Docker::Tag],
612
+ version_tag: Dependabot::Docker::Tag
613
+ )
614
+ .returns(T::Array[Dependabot::Docker::Tag])
615
+ end
616
+ def remove_more_precise_tags(candidate_tags, version_tag)
617
+ return candidate_tags unless version_tag.format == :normal
618
+ return candidate_tags if version_tag.precision > 2
619
+
620
+ candidate_tags.select { |tag| tag.precision <= version_tag.precision }
621
+ end
622
+
565
623
  sig do
566
624
  params(
567
625
  candidate_tags: T::Array[Dependabot::Docker::Tag],
@@ -899,6 +957,130 @@ module Dependabot
899
957
  Dependabot::UpdateCheckers::CooldownCalculation.within_cooldown_window?(release_date, days)
900
958
  end
901
959
 
960
+ # Builds the PackageRelease version for a tag. Non-comparable tags (e.g.
961
+ # "alpine") have no semver, so Docker::Version.new would raise. The version
962
+ # is only consumed by semver-aware version-tag cooldown; the digest cooldown
963
+ # path relies solely on the release date, so a sentinel keeps PackageRelease
964
+ # construction safe for non-comparable tags.
965
+ sig { params(tag: Dependabot::Docker::Tag).returns(Dependabot::Version) }
966
+ def release_version_for(tag)
967
+ Docker::Version.new(tag.name)
968
+ rescue ArgumentError, TypeError
969
+ Dependabot::Version.new("0")
970
+ end
971
+
972
+ # Whether the digest currently served for the given tag was published too
973
+ # recently to satisfy the configured cooldown window. Digest-only refreshes
974
+ # don't change the version string, so the default cooldown window applies
975
+ # (semver-specific windows require a version delta, and the tag may be
976
+ # non-comparable like "alpine"). Fails open (returns false) when the
977
+ # publication date can't be determined, so a missing Last-Modified header
978
+ # never permanently blocks an update.
979
+ sig { params(tag_name: String).returns(T::Boolean) }
980
+ def digest_within_cooldown?(tag_name)
981
+ return false if should_skip_cooldown?
982
+
983
+ cooldown = @update_cooldown
984
+ return false unless cooldown
985
+
986
+ released_at = publication_detail(Tag.new(tag_name))&.released_at
987
+ return false unless released_at
988
+
989
+ Dependabot::UpdateCheckers::CooldownCalculation.within_cooldown_window?(
990
+ released_at, cooldown.default_days
991
+ )
992
+ end
993
+
994
+ # Resolves the digest to write for a tag during a requirement update.
995
+ # Keeps the existing digest for a digest-only refresh that's still within
996
+ # the cooldown window; otherwise resolves the latest digest for the tag.
997
+ sig do
998
+ params(
999
+ current_tag: String,
1000
+ updated_tag: String,
1001
+ current_digest: T.nilable(String)
1002
+ ).returns(T.nilable(String))
1003
+ end
1004
+ def resolved_digest_for(current_tag, updated_tag, current_digest)
1005
+ # Keep the existing digest for a digest-only refresh that's still within
1006
+ # the cooldown window; otherwise resolve the latest digest for the tag.
1007
+ return current_digest if current_digest && updated_tag == current_tag &&
1008
+ digest_within_cooldown?(updated_tag)
1009
+
1010
+ digest_of(updated_tag)
1011
+ end
1012
+
1013
+ # Whether a digest-only update for an unchanged comparable tag is suppressed
1014
+ # by the docker_digest_only_update_suppression experiment. Non-comparable
1015
+ # tags like "latest" or distro codenames are excluded so they still update.
1016
+ sig do
1017
+ params(source_tag: String, latest_tag: Dependabot::Docker::Tag).returns(T::Boolean)
1018
+ end
1019
+ def digest_only_update_suppressed?(source_tag, latest_tag)
1020
+ Dependabot::Experiments.enabled?(:docker_digest_only_update_suppression) &&
1021
+ Tag.new(source_tag).comparable? &&
1022
+ latest_tag.name == source_tag
1023
+ end
1024
+
1025
+ # Whether a digest-only refresh is a no-op for the platform(s) the user
1026
+ # actually consumes. Only applies when the tag is unchanged and the index
1027
+ # digest moved; otherwise this is a genuine version/digest change.
1028
+ sig do
1029
+ params(
1030
+ source: T::Hash[Symbol, T.untyped],
1031
+ current_digest: String,
1032
+ expected_digest: String,
1033
+ digest_only_refresh: T::Boolean
1034
+ ).returns(T::Boolean)
1035
+ end
1036
+ def digest_refresh_is_noop?(source, current_digest, expected_digest, digest_only_refresh)
1037
+ digest_only_refresh &&
1038
+ current_digest != expected_digest &&
1039
+ multiarch_noop?(source, current_digest, expected_digest)
1040
+ end
1041
+
1042
+ # Compares the per-platform manifest digests of the currently-pinned index
1043
+ # against the candidate index. When the Dockerfile pins `--platform`, only
1044
+ # that platform is compared; otherwise every platform must be identical.
1045
+ # Fails open (returns false) whenever a confident comparison isn't possible.
1046
+ sig do
1047
+ params(
1048
+ source: T::Hash[Symbol, T.untyped],
1049
+ current_digest: String,
1050
+ candidate_digest: String
1051
+ ).returns(T::Boolean)
1052
+ end
1053
+ def multiarch_noop?(source, current_digest, candidate_digest)
1054
+ current = platform_digests("sha256:#{current_digest}")
1055
+ candidate = platform_digests("sha256:#{candidate_digest}")
1056
+
1057
+ # Either reference is single-platform or couldn't be fetched: can't prove a no-op.
1058
+ return false if current.nil? || candidate.nil?
1059
+
1060
+ pinned = pinned_platform_key(source[:platform])
1061
+ if pinned
1062
+ current_platform = current[pinned]
1063
+ candidate_platform = candidate[pinned]
1064
+ return false if current_platform.nil? || candidate_platform.nil?
1065
+
1066
+ current_platform == candidate_platform
1067
+ else
1068
+ # No platform pin: only a no-op if nothing changed for any platform.
1069
+ current == candidate
1070
+ end
1071
+ end
1072
+
1073
+ # Normalizes a Dockerfile `--platform` value into a manifest platform key
1074
+ # (e.g. "linux/amd64", "linux/arm64/v8"). Returns nil for build-arg
1075
+ # placeholders like `$BUILDPLATFORM` so we fall back to the strict check.
1076
+ sig { params(platform: T.nilable(String)).returns(T.nilable(String)) }
1077
+ def pinned_platform_key(platform)
1078
+ return nil if platform.nil?
1079
+ return nil unless platform.match?(%r{\A[a-z0-9]+/[a-z0-9]+(?:/[a-z0-9]+)?\z})
1080
+
1081
+ platform
1082
+ end
1083
+
902
1084
  # Fetches the "created" timestamp from the image config blob for a given tag.
903
1085
  # This represents the actual build time, which is more reliable than semver
904
1086
  # for determining which image is truly newer.
@@ -1083,6 +1265,153 @@ module Dependabot
1083
1265
  candidate_created > current_created
1084
1266
  end
1085
1267
 
1268
+ # Returns true when the candidate tag and current tag reference the exact
1269
+ # same image contents. Only multi-arch (manifest list) images are compared
1270
+ # here, by checking that both tags expose the identical set of platforms
1271
+ # with matching per-platform manifest digests. For single-platform images this
1272
+ # returns false (empty digest map), so this check is effectively a no-op and
1273
+ # we fall back to the existing semver/precision selection logic.
1274
+ #
1275
+ # Single-platform current tags are ruled out up front with a cheap HEAD
1276
+ # request, so the default path doesn't pay for a full manifest GET that
1277
+ # would only ever return an empty digest map.
1278
+ sig do
1279
+ params(
1280
+ candidate_tag: Dependabot::Docker::Tag,
1281
+ current_tag: Dependabot::Docker::Tag
1282
+ ).returns(T::Boolean)
1283
+ end
1284
+ def same_image_contents?(candidate_tag, current_tag)
1285
+ # Only manifest lists can match here, so confirm the current tag is
1286
+ # multi-arch with a cheap HEAD request before paying for the full
1287
+ # manifest GET that fetch_platform_digests performs. Single-platform
1288
+ # images (the default path) short-circuit without an extra manifest GET.
1289
+ return false if single_platform_image?(current_tag.name)
1290
+
1291
+ current_digests = fetch_platform_digests(current_tag.name)
1292
+ return false if current_digests.empty?
1293
+
1294
+ candidate_digests = fetch_platform_digests(candidate_tag.name)
1295
+ return false if candidate_digests.empty?
1296
+
1297
+ # The contents are unchanged only when both tags expose the exact same set
1298
+ # of platforms with identical per-platform manifest digests. A candidate
1299
+ # with extra platforms is a different image and must not match.
1300
+ current_digests == candidate_digests
1301
+ end
1302
+
1303
+ # Returns true when a tag points at a single-platform image rather than a
1304
+ # multi-arch manifest list. Uses a HEAD request — the registry reports the
1305
+ # negotiated media type in the Content-Type header — so we avoid the more
1306
+ # expensive full-manifest GET that fetch_platform_digests performs.
1307
+ #
1308
+ # Reuses a manifest list already fetched for the tag when available, and
1309
+ # only treats positively recognised single-platform media types as
1310
+ # single-platform. An ambiguous, missing or errored response returns false
1311
+ # so the caller falls back to the per-platform comparison rather than risk
1312
+ # skipping a real manifest list.
1313
+ sig { params(tag_name: String).returns(T::Boolean) }
1314
+ def single_platform_image?(tag_name)
1315
+ return manifest_list_cache[tag_name].nil? if manifest_list_cache.key?(tag_name)
1316
+ return T.must(single_platform_cache[tag_name]) if single_platform_cache.key?(tag_name)
1317
+
1318
+ single_platform_cache[tag_name] = fetch_single_platform_flag(tag_name)
1319
+ end
1320
+
1321
+ sig { params(tag_name: String).returns(T::Boolean) }
1322
+ def fetch_single_platform_flag(tag_name)
1323
+ response = with_retries(max_attempts: 3, errors: transient_docker_errors) do
1324
+ docker_registry_client.dohead("v2/#{docker_repo_name}/manifests/#{tag_name}")
1325
+ end
1326
+
1327
+ media_type = response.headers[:content_type]&.split(";")&.first&.strip
1328
+ SINGLE_PLATFORM_MANIFEST_TYPES.include?(media_type)
1329
+ rescue DockerRegistry2::Exception => e
1330
+ Dependabot.logger.info(
1331
+ "Failed to determine manifest media type for #{docker_repo_name}:#{tag_name}, " \
1332
+ "falling back to per-platform comparison: #{e.class} - #{e.message}"
1333
+ )
1334
+ false
1335
+ end
1336
+
1337
+ # Fetches a tag's manifest list (multi-arch image index) and returns the
1338
+ # array of per-platform manifest entries, or nil for single-platform images
1339
+ # (not a manifest list). Cached so the platform-inspection methods below
1340
+ # share a single registry GET per tag.
1341
+ sig { params(tag_name: String).returns(T.nilable(T::Array[T::Hash[String, T.untyped]])) }
1342
+ def fetch_manifest_list(tag_name)
1343
+ return manifest_list_cache[tag_name] if manifest_list_cache.key?(tag_name)
1344
+
1345
+ manifest_list_cache[tag_name] = fetch_manifest_list_from_registry(tag_name)
1346
+ rescue DockerRegistry2::Exception, JSON::ParserError => e
1347
+ # Do NOT cache fetch failures. A cached nil is reserved for "definitely
1348
+ # not a manifest list" (single-platform), which single_platform_image?
1349
+ # relies on. Caching a transient error as nil would misclassify the tag
1350
+ # as single-platform and permanently short-circuit same-content
1351
+ # suppression, even if a later manifest fetch would succeed. Returning
1352
+ # nil without caching lets a subsequent call retry.
1353
+ Dependabot.logger.info(
1354
+ "Failed to fetch manifest list for #{docker_repo_name}:#{tag_name}: #{e.message}"
1355
+ )
1356
+ nil
1357
+ end
1358
+
1359
+ sig { params(tag_name: String).returns(T.nilable(T::Array[T::Hash[String, T.untyped]])) }
1360
+ def fetch_manifest_list_from_registry(tag_name)
1361
+ manifest = with_retries(max_attempts: 3, errors: transient_docker_errors) do
1362
+ docker_registry_client.manifest(docker_repo_name, tag_name)
1363
+ end
1364
+
1365
+ media_type = manifest["mediaType"] || manifest[:mediaType]
1366
+ return nil unless MANIFEST_LIST_TYPES.include?(media_type)
1367
+
1368
+ manifest["manifests"] || manifest[:manifests] || []
1369
+ end
1370
+
1371
+ # Fetches a map of platform key (e.g. "linux/amd64") to platform manifest
1372
+ # digest for a tag's manifest list. Returns an empty hash for single-platform
1373
+ # images or when the manifest can't be fetched.
1374
+ sig { params(tag_name: String).returns(T::Hash[String, String]) }
1375
+ def fetch_platform_digests(tag_name)
1376
+ return T.must(platform_digests_cache[tag_name]) if platform_digests_cache.key?(tag_name)
1377
+
1378
+ digests = fetch_platform_digests_from_registry(tag_name)
1379
+ platform_digests_cache[tag_name] = digests
1380
+ digests
1381
+ rescue *transient_docker_errors, DockerRegistry2::RegistryAuthenticationException,
1382
+ RestClient::Forbidden, RestClient::TooManyRequests, JSON::ParserError => e
1383
+ Dependabot.logger.info(
1384
+ "Failed to fetch platform digests for #{docker_repo_name}:#{tag_name}: #{e.message}"
1385
+ )
1386
+ platform_digests_cache[tag_name] = {}
1387
+ {}
1388
+ end
1389
+
1390
+ sig { params(tag_name: String).returns(T::Hash[String, String]) }
1391
+ def fetch_platform_digests_from_registry(tag_name)
1392
+ manifests = fetch_manifest_list(tag_name)
1393
+ return {} unless manifests
1394
+
1395
+ collect_platform_digests(manifests)
1396
+ end
1397
+
1398
+ sig { params(manifests: T.untyped).returns(T::Hash[String, String]) }
1399
+ def collect_platform_digests(manifests)
1400
+ digests = {}
1401
+
1402
+ manifests.each do |m|
1403
+ platform = extract_platform(m)
1404
+ next unless platform
1405
+
1406
+ digest = m["digest"] || m[:digest]
1407
+ next unless digest
1408
+
1409
+ digests[platform_key(platform)] = digest
1410
+ end
1411
+
1412
+ digests
1413
+ end
1414
+
1086
1415
  # Fetches the platform entries from a manifest list for a given tag.
1087
1416
  # Returns nil if the tag is a single-platform image (not a manifest list).
1088
1417
  sig { params(tag_name: String).returns(T.nilable(T::Array[T::Hash[String, T.untyped]])) }
@@ -1103,19 +1432,26 @@ module Dependabot
1103
1432
 
1104
1433
  sig { params(tag_name: String).returns(T.nilable(T::Array[T::Hash[String, T.untyped]])) }
1105
1434
  def fetch_manifest_platforms_from_registry(tag_name)
1106
- manifest = with_retries(max_attempts: 3, errors: transient_docker_errors) do
1107
- docker_registry_client.manifest(docker_repo_name, tag_name)
1108
- end
1109
-
1110
- media_type = manifest["mediaType"] || manifest[:mediaType]
1111
- return nil unless MANIFEST_LIST_TYPES.include?(media_type)
1112
-
1113
- manifests = manifest["manifests"] || manifest[:manifests] || []
1435
+ manifests = fetch_manifest_list(tag_name)
1436
+ return nil unless manifests
1114
1437
 
1115
1438
  # Filter to actual image manifests (exclude attestations/signatures)
1116
1439
  manifests.filter_map { |m| extract_platform(m) }
1117
1440
  end
1118
1441
 
1442
+ # Maps each platform in a manifest list to its per-platform manifest digest,
1443
+ # e.g. { "linux/amd64" => "sha256:…", "linux/arm64/v8" => "sha256:…" }.
1444
+ # Accepts a tag name or a digest reference (e.g. "sha256:…"). Returns nil
1445
+ # for single-platform images or when the manifest can't be fetched, so
1446
+ # callers fail open rather than treat an unknown image as a no-op.
1447
+ sig { params(reference: String).returns(T.nilable(T::Hash[String, String])) }
1448
+ def platform_digests(reference)
1449
+ digests = fetch_platform_digests(reference)
1450
+ return nil if digests.empty?
1451
+
1452
+ digests
1453
+ end
1454
+
1119
1455
  sig { params(manifest_entry: T.untyped).returns(T.nilable(T::Hash[String, T.untyped])) }
1120
1456
  def extract_platform(manifest_entry)
1121
1457
  platform = manifest_entry["platform"] || manifest_entry[:platform]
@@ -1160,14 +1496,9 @@ module Dependabot
1160
1496
 
1161
1497
  sig { params(tag_name: String).returns(T::Hash[String, T.nilable(Time)]) }
1162
1498
  def fetch_all_platform_timestamps_from_registry(tag_name)
1163
- manifest = with_retries(max_attempts: 3, errors: transient_docker_errors) do
1164
- docker_registry_client.manifest(docker_repo_name, tag_name)
1165
- end
1499
+ manifests = fetch_manifest_list(tag_name)
1500
+ return {} unless manifests
1166
1501
 
1167
- media_type = manifest["mediaType"] || manifest[:mediaType]
1168
- return {} unless MANIFEST_LIST_TYPES.include?(media_type)
1169
-
1170
- manifests = manifest["manifests"] || manifest[:manifests] || []
1171
1502
  collect_platform_timestamps(manifests)
1172
1503
  end
1173
1504
 
@@ -1202,6 +1533,22 @@ module Dependabot
1202
1533
  parse_created_from_config_blob(config_digest)
1203
1534
  end
1204
1535
 
1536
+ sig { returns(T::Hash[String, T.nilable(T::Array[T::Hash[String, T.untyped]])]) }
1537
+ def manifest_list_cache
1538
+ @manifest_list_cache ||= T.let(
1539
+ {},
1540
+ T.nilable(T::Hash[String, T.nilable(T::Array[T::Hash[String, T.untyped]])])
1541
+ )
1542
+ end
1543
+
1544
+ sig { returns(T::Hash[String, T::Boolean]) }
1545
+ def single_platform_cache
1546
+ @single_platform_cache ||= T.let(
1547
+ {},
1548
+ T.nilable(T::Hash[String, T::Boolean])
1549
+ )
1550
+ end
1551
+
1205
1552
  sig { returns(T::Hash[String, T.nilable(T::Array[T::Hash[String, T.untyped]])]) }
1206
1553
  def manifest_platforms_cache
1207
1554
  @manifest_platforms_cache ||= T.let(
@@ -1218,6 +1565,14 @@ module Dependabot
1218
1565
  )
1219
1566
  end
1220
1567
 
1568
+ sig { returns(T::Hash[String, T::Hash[String, String]]) }
1569
+ def platform_digests_cache
1570
+ @platform_digests_cache ||= T.let(
1571
+ {},
1572
+ T.nilable(T::Hash[String, T::Hash[String, String]])
1573
+ )
1574
+ end
1575
+
1221
1576
  sig { returns(T::Hash[String, T.nilable(Time)]) }
1222
1577
  def config_created_timestamps
1223
1578
  @config_created_timestamps ||= T.let(
@@ -43,6 +43,7 @@ module Dependabot
43
43
  source[:registry] = parsed_line.fetch("registry") if parsed_line.fetch("registry")
44
44
  source[:tag] = parsed_line.fetch("tag") if parsed_line.fetch("tag")
45
45
  source[:digest] = parsed_line.fetch("digest") if parsed_line.fetch("digest")
46
+ source[:platform] = parsed_line["platform"] if parsed_line["platform"]
46
47
 
47
48
  source
48
49
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-docker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.382.0
4
+ version: 0.384.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.382.0
18
+ version: 0.384.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.382.0
25
+ version: 0.384.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -266,7 +266,7 @@ licenses:
266
266
  - MIT
267
267
  metadata:
268
268
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
269
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.382.0
269
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.384.0
270
270
  rdoc_options: []
271
271
  require_paths:
272
272
  - lib
@@ -281,7 +281,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
281
281
  - !ruby/object:Gem::Version
282
282
  version: 3.3.0
283
283
  requirements: []
284
- rubygems_version: 3.7.2
284
+ rubygems_version: 4.0.14
285
285
  specification_version: 4
286
286
  summary: Provides Dependabot support for Docker
287
287
  test_files: []