• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 21264706899

22 Jan 2026 09:00PM UTC coverage: 80.255% (+1.6%) from 78.666%
21264706899

Pull #23031

github

web-flow
Merge 8385604a3 into d250c80fe
Pull Request #23031: Enable publish without package

32 of 60 new or added lines in 6 files covered. (53.33%)

2 existing lines in 2 files now uncovered.

78788 of 98172 relevant lines covered (80.26%)

3.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.49
/src/python/pants/backend/docker/goals/package_image.py
1
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
from __future__ import annotations
9✔
4

5
import json
9✔
6
import logging
9✔
7
import os
9✔
8
import re
9✔
9
from collections.abc import Iterator
9✔
10
from dataclasses import asdict, dataclass
9✔
11
from functools import partial
9✔
12
from itertools import chain
9✔
13
from typing import Literal, cast
9✔
14

15
# Re-exporting BuiltDockerImage here, as it has its natural home here, but has moved out to resolve
16
# a dependency cycle from docker_build_context.
17
from pants.backend.docker.package_types import BuiltDockerImage as BuiltDockerImage
9✔
18
from pants.backend.docker.registries import DockerRegistries, DockerRegistryOptions
9✔
19
from pants.backend.docker.subsystems.docker_options import DockerOptions
9✔
20
from pants.backend.docker.target_types import (
9✔
21
    DockerBuildKitOptionField,
22
    DockerBuildOptionFieldListOfMultiValueDictMixin,
23
    DockerBuildOptionFieldMixin,
24
    DockerBuildOptionFieldMultiValueDictMixin,
25
    DockerBuildOptionFieldMultiValueMixin,
26
    DockerBuildOptionFieldValueMixin,
27
    DockerBuildOptionFlagFieldMixin,
28
    DockerImageBuildImageOutputField,
29
    DockerImageContextRootField,
30
    DockerImageRegistriesField,
31
    DockerImageRepositoryField,
32
    DockerImageSourceField,
33
    DockerImageTagsField,
34
    DockerImageTagsRequest,
35
    DockerImageTargetStageField,
36
    get_docker_image_tags,
37
)
38
from pants.backend.docker.util_rules.docker_binary import DockerBinary
9✔
39
from pants.backend.docker.util_rules.docker_build_context import (
9✔
40
    DockerBuildContext,
41
    DockerBuildContextRequest,
42
    create_docker_build_context,
43
)
44
from pants.backend.docker.utils import format_rename_suggestion
9✔
45
from pants.core.goals.package import BuiltPackage, OutputPathField, PackageFieldSet
9✔
46
from pants.engine.fs import CreateDigest, FileContent
9✔
47
from pants.engine.internals.graph import resolve_target
9✔
48
from pants.engine.intrinsics import create_digest, execute_process
9✔
49
from pants.engine.process import ProcessExecutionFailure
9✔
50
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
9✔
51
from pants.engine.target import InvalidFieldException, Target, WrappedTargetRequest
9✔
52
from pants.engine.unions import UnionMembership, UnionRule
9✔
53
from pants.option.global_options import GlobalOptions, KeepSandboxes
9✔
54
from pants.util.strutil import bullet_list, softwrap
9✔
55
from pants.util.value_interpolation import InterpolationContext, InterpolationError
9✔
56

57
logger = logging.getLogger(__name__)
9✔
58

59

60
class DockerImageTagValueError(InterpolationError):
9✔
61
    pass
9✔
62

63

64
class DockerRepositoryNameError(InterpolationError):
9✔
65
    pass
9✔
66

67

68
class DockerBuildTargetStageError(ValueError):
9✔
69
    pass
9✔
70

71

72
class DockerImageOptionValueError(InterpolationError):
9✔
73
    pass
9✔
74

75

76
@dataclass(frozen=True)
9✔
77
class DockerPackageFieldSet(PackageFieldSet):
9✔
78
    required_fields = (DockerImageSourceField, DockerImageBuildImageOutputField)
9✔
79

80
    context_root: DockerImageContextRootField
9✔
81
    registries: DockerImageRegistriesField
9✔
82
    repository: DockerImageRepositoryField
9✔
83
    source: DockerImageSourceField
9✔
84
    tags: DockerImageTagsField
9✔
85
    target_stage: DockerImageTargetStageField
9✔
86
    output_path: OutputPathField
9✔
87
    output: DockerImageBuildImageOutputField
9✔
88

89
    def has_side_effects(self) -> bool:
9✔
NEW
90
        value_or_default = self.output.value or self.output.default
×
NEW
91
        return value_or_default.get("push") == "true" or value_or_default["type"] == "registry"
×
92

93
    def format_tag(self, tag: str, interpolation_context: InterpolationContext) -> str:
9✔
94
        source = InterpolationContext.TextSource(
2✔
95
            address=self.address, target_alias="docker_image", field_alias=self.tags.alias
96
        )
97
        return interpolation_context.format(tag, source=source, error_cls=DockerImageTagValueError)
2✔
98

99
    def format_repository(
9✔
100
        self,
101
        default_repository: str,
102
        interpolation_context: InterpolationContext,
103
        registry: DockerRegistryOptions | None = None,
104
    ) -> str:
105
        repository_context = InterpolationContext.from_dict(
2✔
106
            {
107
                "name": self.address.target_name,
108
                "directory": os.path.basename(self.address.spec_path),
109
                "full_directory": self.address.spec_path,
110
                "parent_directory": os.path.basename(os.path.dirname(self.address.spec_path)),
111
                "default_repository": default_repository,
112
                "target_repository": self.repository.value or default_repository,
113
                **interpolation_context,
114
            }
115
        )
116
        if registry and registry.repository:
2✔
117
            repository_text = registry.repository
1✔
118
            source = InterpolationContext.TextSource(
1✔
119
                options_scope=f"[docker.registries.{registry.alias or registry.address}].repository"
120
            )
121
        elif self.repository.value:
2✔
122
            repository_text = self.repository.value
1✔
123
            source = InterpolationContext.TextSource(
1✔
124
                address=self.address, target_alias="docker_image", field_alias=self.repository.alias
125
            )
126
        else:
127
            repository_text = default_repository
2✔
128
            source = InterpolationContext.TextSource(options_scope="[docker].default_repository")
2✔
129
        return repository_context.format(
2✔
130
            repository_text, source=source, error_cls=DockerRepositoryNameError
131
        ).lower()
132

133
    def format_image_ref_tags(
9✔
134
        self,
135
        repository: str,
136
        tags: tuple[str, ...],
137
        interpolation_context: InterpolationContext,
138
        uses_local_alias,
139
    ) -> Iterator[ImageRefTag]:
140
        for tag in tags:
2✔
141
            formatted = self.format_tag(tag, interpolation_context)
2✔
142
            yield ImageRefTag(
2✔
143
                template=tag,
144
                formatted=formatted,
145
                full_name=":".join(s for s in [repository, formatted] if s),
146
                uses_local_alias=uses_local_alias,
147
            )
148

149
    def image_refs(
9✔
150
        self,
151
        default_repository: str,
152
        registries: DockerRegistries,
153
        interpolation_context: InterpolationContext,
154
        additional_tags: tuple[str, ...] = (),
155
    ) -> Iterator[ImageRefRegistry]:
156
        """The per-registry image refs: each returned element is a collection of the tags applied to
157
        the image in a single registry.
158

159
        In the Docker world, the term `tag` is used both for what we here prefer to call the image
160
        `ref`, as well as for the image version, or tag, that is at the end of the image name
161
        separated with a colon. By introducing the image `ref` we can retain the use of `tag` for
162
        the version part of the image name.
163

164
        This function returns all image refs to apply to the Docker image, grouped by
165
        registry. Within each registry, the `tags` attribute contains a metadata about each tag in
166
        the context of that registry, and the `full_name` attribute of each `ImageRefTag` provides
167
        the image ref, of the following form:
168

169
            [<registry>/]<repository-name>[:<tag>]
170

171
        Where the `<repository-name>` may contain any number of separating slashes `/`, depending on
172
        the `default_repository` from configuration or the `repository` field on the target
173
        `docker_image`.
174

175
        This method will always return at least one `ImageRefRegistry`, and there will be at least
176
        one tag.
177
        """
178
        image_tags = (self.tags.value or ()) + additional_tags
2✔
179
        registries_options = tuple(registries.get(*(self.registries.value or [])))
2✔
180
        if not registries_options:
2✔
181
            # The image name is also valid as image ref without registry.
182
            repository = self.format_repository(default_repository, interpolation_context)
2✔
183
            yield ImageRefRegistry(
2✔
184
                registry=None,
185
                repository=repository,
186
                tags=tuple(
187
                    self.format_image_ref_tags(
188
                        repository, image_tags, interpolation_context, uses_local_alias=False
189
                    )
190
                ),
191
            )
192
            return
2✔
193

194
        for registry in registries_options:
2✔
195
            repository = self.format_repository(default_repository, interpolation_context, registry)
2✔
196
            address_repository = "/".join([registry.address, repository])
2✔
197
            if registry.use_local_alias and registry.alias:
2✔
198
                alias_repository = "/".join([registry.alias, repository])
1✔
199
            else:
200
                alias_repository = None
2✔
201

202
            yield ImageRefRegistry(
2✔
203
                registry=registry,
204
                repository=repository,
205
                tags=(
206
                    *self.format_image_ref_tags(
207
                        address_repository,
208
                        image_tags + registry.extra_image_tags,
209
                        interpolation_context,
210
                        uses_local_alias=False,
211
                    ),
212
                    *(
213
                        self.format_image_ref_tags(
214
                            alias_repository,
215
                            image_tags + registry.extra_image_tags,
216
                            interpolation_context,
217
                            uses_local_alias=True,
218
                        )
219
                        if alias_repository
220
                        else []
221
                    ),
222
                ),
223
            )
224

225
    def get_context_root(self, default_context_root: str) -> str:
9✔
226
        """Examines `default_context_root` and `self.context_root.value` and translates that to a
227
        context root for the Docker build operation.
228

229
        That is, in the configuration/field value, the context root is relative to build root when
230
        in the form `path/..` (implies semantics as `//path/..` for target addresses) or the BUILD
231
        file when `./path/..`.
232

233
        The returned path is always relative to the build root.
234
        """
235
        if self.context_root.value is not None:
1✔
236
            context_root = self.context_root.value
1✔
237
        else:
238
            context_root = cast(
1✔
239
                str, self.context_root.compute_value(default_context_root, self.address)
240
            )
241
        if context_root.startswith("./"):
1✔
242
            context_root = os.path.join(self.address.spec_path, context_root)
1✔
243
        return os.path.normpath(context_root)
1✔
244

245

246
@dataclass(frozen=True)
9✔
247
class ImageRefRegistry:
9✔
248
    registry: DockerRegistryOptions | None
9✔
249
    repository: str
9✔
250
    tags: tuple[ImageRefTag, ...]
9✔
251

252

253
@dataclass(frozen=True)
9✔
254
class ImageRefTag:
9✔
255
    template: str
9✔
256
    formatted: str
9✔
257
    full_name: str
9✔
258
    uses_local_alias: bool
9✔
259

260

261
@dataclass(frozen=True)
9✔
262
class DockerInfoV1:
9✔
263
    """The format of the `$target_name.docker-info.json` file."""
264

265
    version: Literal[1]
9✔
266
    image_id: str
9✔
267
    # It'd be good to include the digest here (e.g. to allow 'docker run
268
    # registry/repository@digest'), but that is only known after pushing to a V2 registry
269

270
    registries: list[DockerInfoV1Registry]
9✔
271

272
    @staticmethod
9✔
273
    def serialize(image_refs: tuple[ImageRefRegistry, ...], image_id: str) -> bytes:
9✔
274
        # make sure these are in a consistent order (the exact order doesn't matter
275
        # so much), no matter how they were configured
276
        sorted_refs = sorted(image_refs, key=lambda r: r.registry.address if r.registry else "")
1✔
277

278
        info = DockerInfoV1(
1✔
279
            version=1,
280
            image_id=image_id,
281
            registries=[
282
                DockerInfoV1Registry(
283
                    alias=r.registry.alias if r.registry and r.registry.alias else None,
284
                    address=r.registry.address if r.registry else None,
285
                    repository=r.repository,
286
                    tags=[
287
                        DockerInfoV1ImageTag(
288
                            template=t.template,
289
                            tag=t.formatted,
290
                            uses_local_alias=t.uses_local_alias,
291
                            name=t.full_name,
292
                        )
293
                        # consistent order, as above
294
                        for t in sorted(r.tags, key=lambda t: t.full_name)
295
                    ],
296
                )
297
                for r in sorted_refs
298
            ],
299
        )
300

301
        return json.dumps(asdict(info)).encode()
1✔
302

303

304
@dataclass(frozen=True)
9✔
305
class DockerInfoV1Registry:
9✔
306
    # set if registry was specified as `@something`
307
    alias: str | None
9✔
308
    address: str | None
9✔
309
    repository: str
9✔
310
    tags: list[DockerInfoV1ImageTag]
9✔
311

312

313
@dataclass(frozen=True)
9✔
314
class DockerInfoV1ImageTag:
9✔
315
    template: str
9✔
316
    tag: str
9✔
317
    uses_local_alias: bool
9✔
318
    # for convenience, include the concatenated registry/repository:tag name (using this tag)
319
    name: str
9✔
320

321

322
def get_build_options(
9✔
323
    context: DockerBuildContext,
324
    field_set: DockerPackageFieldSet,
325
    global_target_stage_option: str | None,
326
    global_build_hosts_options: dict | None,
327
    global_build_no_cache_option: bool | None,
328
    use_buildx_option: bool,
329
    target: Target,
330
) -> Iterator[str]:
331
    # Build options from target fields inheriting from DockerBuildOptionFieldMixin
332
    for field_type in target.field_types:
1✔
333
        if issubclass(field_type, DockerBuildKitOptionField):
1✔
334
            if use_buildx_option is not True:
1✔
335
                if target[field_type].value != target[field_type].default:
1✔
336
                    raise DockerImageOptionValueError(
1✔
337
                        f"The {target[field_type].alias} field on the = `{target.alias}` target in `{target.address}` was set to `{target[field_type].value}`"
338
                        f" and buildx is not enabled. Buildx must be enabled via the Docker subsystem options in order to use this field."
339
                    )
340
                else:
341
                    # Case where BuildKit option has a default value - still should not be generated
342
                    continue
1✔
343

344
        if issubclass(
1✔
345
            field_type,
346
            (
347
                DockerBuildOptionFieldMixin,
348
                DockerBuildOptionFieldMultiValueDictMixin,
349
                DockerBuildOptionFieldListOfMultiValueDictMixin,
350
                DockerBuildOptionFieldValueMixin,
351
                DockerBuildOptionFieldMultiValueMixin,
352
                DockerBuildOptionFlagFieldMixin,
353
            ),
354
        ):
355
            source = InterpolationContext.TextSource(
1✔
356
                address=target.address, target_alias=target.alias, field_alias=field_type.alias
357
            )
358
            format = partial(
1✔
359
                context.interpolation_context.format,
360
                source=source,
361
                error_cls=DockerImageOptionValueError,
362
            )
363
            yield from target[field_type].options(
1✔
364
                format, global_build_hosts_options=global_build_hosts_options
365
            )
366

367
    # Target stage
368
    target_stage = None
1✔
369
    if global_target_stage_option in context.stages:
1✔
370
        target_stage = global_target_stage_option
1✔
371
    elif field_set.target_stage.value:
1✔
372
        target_stage = field_set.target_stage.value
1✔
373
        if target_stage not in context.stages:
1✔
374
            raise DockerBuildTargetStageError(
1✔
375
                f"The {field_set.target_stage.alias!r} field in `{target.alias}` "
376
                f"{field_set.address} was set to {target_stage!r}"
377
                + (
378
                    f", but there is no such stage in `{context.dockerfile}`. "
379
                    f"Available stages: {', '.join(context.stages)}."
380
                    if context.stages
381
                    else f", but there are no named stages in `{context.dockerfile}`."
382
                )
383
            )
384

385
    if target_stage:
1✔
386
        yield from ("--target", target_stage)
1✔
387

388
    if global_build_no_cache_option:
1✔
389
        yield "--no-cache"
1✔
390

391

392
@rule
9✔
393
async def build_docker_image(
9✔
394
    field_set: DockerPackageFieldSet,
395
    options: DockerOptions,
396
    global_options: GlobalOptions,
397
    docker: DockerBinary,
398
    keep_sandboxes: KeepSandboxes,
399
    union_membership: UnionMembership,
400
) -> BuiltPackage:
401
    """Build a Docker image using `docker build`."""
402
    context, wrapped_target = await concurrently(
1✔
403
        create_docker_build_context(
404
            DockerBuildContextRequest(
405
                address=field_set.address,
406
                build_upstream_images=True,
407
            ),
408
            **implicitly(),
409
        ),
410
        resolve_target(
411
            WrappedTargetRequest(field_set.address, description_of_origin="<infallible>"),
412
            **implicitly(),
413
        ),
414
    )
415

416
    image_tags_requests = union_membership.get(DockerImageTagsRequest)
1✔
417
    additional_image_tags = await concurrently(
1✔
418
        get_docker_image_tags(
419
            **implicitly({image_tags_request_cls(wrapped_target.target): DockerImageTagsRequest})
420
        )
421
        for image_tags_request_cls in image_tags_requests
422
        if image_tags_request_cls.is_applicable(wrapped_target.target)
423
    )
424

425
    image_refs = tuple(
1✔
426
        field_set.image_refs(
427
            default_repository=options.default_repository,
428
            registries=options.registries(),
429
            interpolation_context=context.interpolation_context,
430
            additional_tags=tuple(chain.from_iterable(additional_image_tags)),
431
        )
432
    )
433
    tags = tuple(tag.full_name for registry in image_refs for tag in registry.tags)
1✔
434
    if not tags:
1✔
435
        raise InvalidFieldException(
1✔
436
            softwrap(
437
                f"""
438
                The `{DockerImageTagsField.alias}` field in target {field_set.address} must not be
439
                empty, unless there is a custom plugin providing additional tags using the
440
                `DockerImageTagsRequest` union type.
441
                """
442
            )
443
        )
444

445
    # Mix the upstream image ids into the env to ensure that Pants invalidates this
446
    # image-building process correctly when an upstream image changes, even though the
447
    # process itself does not consume this data.
448
    env = {
1✔
449
        **context.build_env.environment,
450
        "__UPSTREAM_IMAGE_IDS": ",".join(context.upstream_image_ids),
451
    }
452
    context_root = field_set.get_context_root(options.default_context_root)
1✔
453
    process = docker.build_image(
1✔
454
        build_args=context.build_args,
455
        digest=context.digest,
456
        dockerfile=context.dockerfile,
457
        context_root=context_root,
458
        env=env,
459
        tags=tags,
460
        use_buildx=options.use_buildx,
461
        extra_args=tuple(
462
            get_build_options(
463
                context=context,
464
                field_set=field_set,
465
                global_target_stage_option=options.build_target_stage,
466
                global_build_hosts_options=options.build_hosts,
467
                global_build_no_cache_option=options.build_no_cache,
468
                use_buildx_option=options.use_buildx,
469
                target=wrapped_target.target,
470
            )
471
        ),
472
    )
473
    result = await execute_process(process, **implicitly())
1✔
474

475
    if result.exit_code != 0:
1✔
476
        msg = f"Docker build failed for `docker_image` {field_set.address}."
1✔
477
        if options.suggest_renames:
1✔
478
            maybe_help_msg = format_docker_build_context_help_message(
1✔
479
                context_root=context_root,
480
                context=context,
481
                colors=global_options.colors,
482
            )
483
            if maybe_help_msg:
1✔
484
                msg += " " + maybe_help_msg
1✔
485

486
        logger.warning(msg)
1✔
487

488
        raise ProcessExecutionFailure(
1✔
489
            result.exit_code,
490
            result.stdout,
491
            result.stderr,
492
            process.description,
493
            keep_sandboxes=keep_sandboxes,
494
        )
495

496
    image_id = parse_image_id_from_docker_build_output(docker, result.stdout, result.stderr)
1✔
497
    docker_build_output_msg = "\n".join(
1✔
498
        (
499
            f"Docker build output for {tags[0]}:",
500
            "stdout:",
501
            result.stdout.decode(),
502
            "stderr:",
503
            result.stderr.decode(),
504
        )
505
    )
506

507
    if options.build_verbose:
1✔
508
        logger.info(docker_build_output_msg)
×
509
    else:
510
        logger.debug(docker_build_output_msg)
1✔
511

512
    metadata_filename = field_set.output_path.value_or_default(file_ending="docker-info.json")
1✔
513
    metadata = DockerInfoV1.serialize(image_refs, image_id=image_id)
1✔
514
    digest = await create_digest(CreateDigest([FileContent(metadata_filename, metadata)]))
1✔
515

516
    return BuiltPackage(
1✔
517
        digest,
518
        (BuiltDockerImage.create(image_id, tags, metadata_filename),),
519
    )
520

521

522
def parse_image_id_from_docker_build_output(docker: DockerBinary, *outputs: bytes) -> str:
9✔
523
    """Outputs are typically the stdout/stderr pair from the `docker build` process."""
524
    # NB: We use the extracted image id for invalidation. The short_id may theoretically
525
    #  not be unique enough, although in a non adversarial situation, this is highly unlikely
526
    #  to be an issue in practice.
527
    if docker.is_podman:
1✔
528
        for output in outputs:
1✔
529
            try:
1✔
530
                _, image_id, success, *__ = reversed(output.decode().split("\n"))
1✔
531
            except ValueError:
×
532
                continue
×
533

534
            if success.startswith("Successfully tagged"):
1✔
535
                return image_id
1✔
536

537
    else:
538
        image_id_regexp = re.compile(
1✔
539
            "|".join(
540
                (
541
                    # BuildKit output.
542
                    r"(writing image (?P<digest>sha256:\S+))",
543
                    # BuildKit with containerd-snapshotter output.
544
                    r"(exporting manifest list (?P<manifest_list>sha256:\S+))",
545
                    # BuildKit with containerd-snapshotter output and no attestation.
546
                    r"(exporting manifest (?P<manifest>sha256:\S+))",
547
                    # Docker output.
548
                    r"(Successfully built (?P<short_id>\S+))",
549
                ),
550
            )
551
        )
552
        for output in outputs:
1✔
553
            image_id_match = next(
1✔
554
                (
555
                    match
556
                    for match in (
557
                        re.search(image_id_regexp, line)
558
                        for line in reversed(output.decode().split("\n"))
559
                    )
560
                    if match
561
                ),
562
                None,
563
            )
564
            if image_id_match:
1✔
565
                image_id = (
1✔
566
                    image_id_match.group("digest")
567
                    or image_id_match.group("short_id")
568
                    or image_id_match.group("manifest_list")
569
                    or image_id_match.group("manifest")
570
                )
571
                return image_id
1✔
572

573
    return "<unknown>"
1✔
574

575

576
def format_docker_build_context_help_message(
9✔
577
    context_root: str, context: DockerBuildContext, colors: bool
578
) -> str | None:
579
    paths_outside_context_root: list[str] = []
1✔
580

581
    def _chroot_context_paths(paths: tuple[str, str]) -> tuple[str, str]:
1✔
582
        """Adjust the context paths in `copy_source_vs_context_source` for `context_root`."""
583
        instruction_path, context_path = paths
1✔
584
        if not context_path:
1✔
585
            return paths
×
586
        dst = os.path.relpath(context_path, context_root)
1✔
587
        if dst.startswith("../"):
1✔
588
            paths_outside_context_root.append(context_path)
1✔
589
            return ("", "")
1✔
590
        if instruction_path == dst:
1✔
591
            return ("", "")
1✔
592
        return instruction_path, dst
1✔
593

594
    # Adjust context paths based on `context_root`.
595
    copy_source_vs_context_source: tuple[tuple[str, str], ...] = tuple(
1✔
596
        filter(any, map(_chroot_context_paths, context.copy_source_vs_context_source))
597
    )
598

599
    if not (copy_source_vs_context_source or paths_outside_context_root):
1✔
600
        # No issues found.
601
        return None
×
602

603
    msg = ""
1✔
604
    has_unsourced_copy = any(src for src, _ in copy_source_vs_context_source)
1✔
605
    if has_unsourced_copy:
1✔
606
        msg += (
1✔
607
            f"The {context.dockerfile} has `COPY` instructions for source files that may not have "
608
            f"been found in the Docker build context.\n\n"
609
        )
610

611
        renames = sorted(
1✔
612
            format_rename_suggestion(src, dst, colors=colors)
613
            for src, dst in copy_source_vs_context_source
614
            if src and dst
615
        )
616
        if renames:
1✔
617
            msg += (
1✔
618
                f"However there are possible matches. Please review the following list of "
619
                f"suggested renames:\n\n{bullet_list(renames)}\n\n"
620
            )
621

622
        unknown = sorted(src for src, dst in copy_source_vs_context_source if src and not dst)
1✔
623
        if unknown:
1✔
624
            msg += (
×
625
                f"The following files were not found in the Docker build context:\n\n"
626
                f"{bullet_list(unknown)}\n\n"
627
            )
628

629
    unreferenced = sorted(dst for src, dst in copy_source_vs_context_source if dst and not src)
1✔
630
    if unreferenced:
1✔
631
        msg += (
1✔
632
            f"There are files in the Docker build context that were not referenced by "
633
            f"any `COPY` instruction (this is not an error):\n\n{bullet_list(unreferenced, 10)}\n\n"
634
        )
635

636
    if paths_outside_context_root:
1✔
637
        unreachable = sorted({os.path.dirname(pth) for pth in paths_outside_context_root})
1✔
638
        context_paths = tuple(dst for src, dst in context.copy_source_vs_context_source if dst)
1✔
639
        new_context_root = os.path.commonpath(context_paths)
1✔
640
        msg += (
1✔
641
            "There are unreachable files in these directories, excluded from the build context "
642
            f"due to `context_root` being {context_root!r}:\n\n{bullet_list(unreachable, 10)}\n\n"
643
            f"Suggested `context_root` setting is {new_context_root!r} in order to include all "
644
            "files in the build context, otherwise relocate the files to be part of the current "
645
            f"`context_root` {context_root!r}."
646
        )
647

648
    return msg
1✔
649

650

651
def rules():
9✔
652
    return [
7✔
653
        *collect_rules(),
654
        UnionRule(PackageFieldSet, DockerPackageFieldSet),
655
    ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc