diff --git a/pkg/api/types.go b/pkg/api/types.go index b1f9d41c90..8c0123b8f8 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -792,6 +792,16 @@ const ( ClusterHive Cluster = "hosted-mgmt" ) +// DockerfileEntry specifies a Dockerfile whose build output determines +// whether a test should run. +type DockerfileEntry struct { + // Path is the path to the Dockerfile relative to the repo root. + Path string `json:"path"` + // GoBinaryTargets is an optional list of Go build targets (e.g., "./cmd/foo"). + // When set, enables go-dependency-aware filtering for COPY-all Dockerfiles. + GoBinaryTargets []string `json:"go_binary_targets,omitempty"` +} + // TestStepConfiguration describes a step that runs a // command in one of the previously built images and then // gathers artifacts from that step. @@ -893,6 +903,12 @@ type TestStepConfiguration struct { // stage of the pipeline run if all changed files match that regex. PipelineSkipIfOnlyChanged string `json:"pipeline_skip_if_only_changed,omitempty"` + // PipelineRunIfDockerfileChanged determines whether to run a test based on + // whether the container image built by the specified Dockerfile(s) would change. + // Instead of hand-maintained regex, it resolves which files matter by parsing + // Dockerfile COPY/ADD instructions. + PipelineRunIfDockerfileChanged []DockerfileEntry `json:"pipeline_run_if_dockerfile_changed,omitempty"` + // Timeout overrides maximum prowjob duration Timeout *prowv1.Duration `json:"timeout,omitempty"` diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index ca22a1f138..ffb1808919 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -472,6 +472,26 @@ func (in *DockerConfigSpec) DeepCopy() *DockerConfigSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DockerfileEntry) DeepCopyInto(out *DockerfileEntry) { + *out = *in + if in.GoBinaryTargets != nil { + in, out := &in.GoBinaryTargets, &out.GoBinaryTargets + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DockerfileEntry. +func (in *DockerfileEntry) DeepCopy() *DockerfileEntry { + if in == nil { + return nil + } + out := new(DockerfileEntry) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExternalImage) DeepCopyInto(out *ExternalImage) { *out = *in @@ -2389,6 +2409,13 @@ func (in *TestStepConfiguration) DeepCopyInto(out *TestStepConfiguration) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.PipelineRunIfDockerfileChanged != nil { + in, out := &in.PipelineRunIfDockerfileChanged, &out.PipelineRunIfDockerfileChanged + *out = make([]DockerfileEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Timeout != nil { in, out := &in.Timeout, &out.Timeout *out = new(v1.Duration) diff --git a/pkg/prowgen/prowgen.go b/pkg/prowgen/prowgen.go index 229d6edb25..e5dd4d6fe9 100644 --- a/pkg/prowgen/prowgen.go +++ b/pkg/prowgen/prowgen.go @@ -1,6 +1,7 @@ package prowgen import ( + "encoding/json" "fmt" "hash/fnv" @@ -226,6 +227,7 @@ func handlePresubmit(g *prowJobBaseBuilder, element cioperatorapi.TestStepConfig presubmit := generatePresubmitForTest(g, name, info, func(options *generatePresubmitOptions) { options.pipelineRunIfChanged = element.PipelineRunIfChanged options.pipelineSkipIfOnlyChanged = element.PipelineSkipIfOnlyChanged + options.pipelineRunIfDockerfileChanged = element.PipelineRunIfDockerfileChanged options.Capabilities = element.Capabilities options.runIfChanged = element.RunIfChanged options.skipIfOnlyChanged = element.SkipIfOnlyChanged @@ -244,21 +246,22 @@ func handlePresubmit(g *prowJobBaseBuilder, element cioperatorapi.TestStepConfig } type generatePresubmitOptions struct { - pipelineRunIfChanged string - pipelineSkipIfOnlyChanged string - Capabilities []string - runIfChanged string - skipIfOnlyChanged string - skipBranches []string - defaultDisable bool - optional bool - disableRehearsal bool - maxConcurrency int - slackReporterConfig *cioperatorapi.SlackReporterConfig + pipelineRunIfChanged string + pipelineSkipIfOnlyChanged string + pipelineRunIfDockerfileChanged []cioperatorapi.DockerfileEntry + Capabilities []string + runIfChanged string + skipIfOnlyChanged string + skipBranches []string + defaultDisable bool + optional bool + disableRehearsal bool + maxConcurrency int + slackReporterConfig *cioperatorapi.SlackReporterConfig } func (opts *generatePresubmitOptions) shouldAlwaysRun() bool { - return opts.runIfChanged == "" && opts.skipIfOnlyChanged == "" && !opts.defaultDisable && opts.pipelineRunIfChanged == "" && opts.pipelineSkipIfOnlyChanged == "" + return opts.runIfChanged == "" && opts.skipIfOnlyChanged == "" && !opts.defaultDisable && opts.pipelineRunIfChanged == "" && opts.pipelineSkipIfOnlyChanged == "" && len(opts.pipelineRunIfDockerfileChanged) == 0 } type generatePresubmitOption func(options *generatePresubmitOptions) @@ -306,6 +309,14 @@ func generatePresubmitForTest(jobBaseBuilder *prowJobBaseBuilder, name string, i base.Annotations["pipeline_skip_if_only_changed"] = opts.pipelineSkipIfOnlyChanged pipelineOpt = true } + if len(opts.pipelineRunIfDockerfileChanged) > 0 { + if base.Annotations == nil { + base.Annotations = make(map[string]string) + } + data, _ := json.Marshal(opts.pipelineRunIfDockerfileChanged) + base.Annotations["pipeline_run_if_dockerfile_changed"] = string(data) + pipelineOpt = true + } triggerCommand := prowconfig.DefaultTriggerFor(shortName) if opts.defaultDisable && opts.runIfChanged == "" && opts.skipIfOnlyChanged == "" && !opts.optional && !pipelineOpt { triggerCommand = fmt.Sprintf(`(?m)^/test( | .* )(%s|%s),?($|\s.*)`, shortName, "remaining-required") diff --git a/pkg/prowgen/prowgen_test.go b/pkg/prowgen/prowgen_test.go index 5ca736e74e..1c8877127e 100644 --- a/pkg/prowgen/prowgen_test.go +++ b/pkg/prowgen/prowgen_test.go @@ -210,6 +210,28 @@ func TestGeneratePresubmitForTest(t *testing.T) { options.pipelineSkipIfOnlyChanged = "^docs/" }, }, + { + description: "presubmit with always_run=false and pipeline_run_if_dockerfile_changed", + test: ciop.TestStepConfiguration{As: "testname"}, + repoInfo: &ciop.Metadata{Org: "org", Repo: "repo", Branch: "branch"}, + generateOption: func(options *generatePresubmitOptions) { + options.defaultDisable = false + options.pipelineRunIfDockerfileChanged = []ciop.DockerfileEntry{ + {Path: "Dockerfile"}, + } + }, + }, + { + description: "presubmit with always_run but pipeline_run_if_dockerfile_changed set", + test: ciop.TestStepConfiguration{As: "testname"}, + repoInfo: &ciop.Metadata{Org: "org", Repo: "repo", Branch: "branch"}, + generateOption: func(options *generatePresubmitOptions) { + options.defaultDisable = true + options.pipelineRunIfDockerfileChanged = []ciop.DockerfileEntry{ + {Path: "Dockerfile", GoBinaryTargets: []string{"./cmd/server"}}, + } + }, + }, { description: "presubmit with always_run but optional true", test: ciop.TestStepConfiguration{As: "testname"}, diff --git a/pkg/prowgen/testdata/zz_fixture_TestGeneratePresubmitForTest_presubmit_with_always_run_but_pipeline_run_if_dockerfile_changed_set.yaml b/pkg/prowgen/testdata/zz_fixture_TestGeneratePresubmitForTest_presubmit_with_always_run_but_pipeline_run_if_dockerfile_changed_set.yaml new file mode 100644 index 0000000000..4f993c2af1 --- /dev/null +++ b/pkg/prowgen/testdata/zz_fixture_TestGeneratePresubmitForTest_presubmit_with_always_run_but_pipeline_run_if_dockerfile_changed_set.yaml @@ -0,0 +1,16 @@ +agent: kubernetes +always_run: false +annotations: + pipeline_run_if_dockerfile_changed: '[{"path":"Dockerfile","go_binary_targets":["./cmd/server"]}]' +branches: +- ^branch$ +- ^branch- +context: ci/prow/testname +decorate: true +decoration_config: + skip_cloning: true +labels: + pj-rehearse.openshift.io/can-be-rehearsed: "true" +name: pull-ci-org-repo-branch-testname +rerun_command: /test testname +trigger: (?m)^/test( | .* )testname,?($|\s.*) diff --git a/pkg/prowgen/testdata/zz_fixture_TestGeneratePresubmitForTest_presubmit_with_always_run_false_and_pipeline_run_if_dockerfile_changed.yaml b/pkg/prowgen/testdata/zz_fixture_TestGeneratePresubmitForTest_presubmit_with_always_run_false_and_pipeline_run_if_dockerfile_changed.yaml new file mode 100644 index 0000000000..94864e1911 --- /dev/null +++ b/pkg/prowgen/testdata/zz_fixture_TestGeneratePresubmitForTest_presubmit_with_always_run_false_and_pipeline_run_if_dockerfile_changed.yaml @@ -0,0 +1,16 @@ +agent: kubernetes +always_run: false +annotations: + pipeline_run_if_dockerfile_changed: '[{"path":"Dockerfile"}]' +branches: +- ^branch$ +- ^branch- +context: ci/prow/testname +decorate: true +decoration_config: + skip_cloning: true +labels: + pj-rehearse.openshift.io/can-be-rehearsed: "true" +name: pull-ci-org-repo-branch-testname +rerun_command: /test testname +trigger: (?m)^/test( | .* )testname,?($|\s.*) diff --git a/pkg/validation/config.go b/pkg/validation/config.go index 1fada5ff15..7bba99b13d 100644 --- a/pkg/validation/config.go +++ b/pkg/validation/config.go @@ -314,22 +314,30 @@ func validateBuildRootImageStreamTag(ctx *configContext, buildRoot api.ImageStre return validationErrors } -func validateRunIfChangedExclusivity(runIfChanged, skipIfOnlyChanged, pipelineRunIfChanged, pipelineSkipIfOnlyChanged string) error { +func validateRunIfChangedExclusivity(runIfChanged, skipIfOnlyChanged, pipelineRunIfChanged, pipelineSkipIfOnlyChanged string, pipelineRunIfDockerfileChanged []api.DockerfileEntry) error { set := 0 for _, f := range []string{runIfChanged, skipIfOnlyChanged, pipelineRunIfChanged, pipelineSkipIfOnlyChanged} { if f != "" { set++ } } + if len(pipelineRunIfDockerfileChanged) > 0 { + set++ + } if set > 1 { - return fmt.Errorf("`run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, and `pipeline_skip_if_only_changed` are mutually exclusive") + return fmt.Errorf("`run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, `pipeline_skip_if_only_changed`, and `pipeline_run_if_dockerfile_changed` are mutually exclusive") + } + for i, entry := range pipelineRunIfDockerfileChanged { + if entry.Path == "" { + return fmt.Errorf("`pipeline_run_if_dockerfile_changed[%d].path` must be set", i) + } } return nil } func validateImageConfiguration(ctx *configContext, images api.ImageConfiguration) []error { var validationErrors []error - if err := validateRunIfChangedExclusivity(images.RunIfChanged, images.SkipIfOnlyChanged, images.PipelineRunIfChanged, images.PipelineSkipIfOnlyChanged); err != nil { + if err := validateRunIfChangedExclusivity(images.RunIfChanged, images.SkipIfOnlyChanged, images.PipelineRunIfChanged, images.PipelineSkipIfOnlyChanged, nil); err != nil { validationErrors = append(validationErrors, ctx.errorf("%s", err)) } validationErrors = append(validationErrors, ValidateImages(ctx, images.Items)...) diff --git a/pkg/validation/config_test.go b/pkg/validation/config_test.go index 33e4a4c418..59a7cd7183 100644 --- a/pkg/validation/config_test.go +++ b/pkg/validation/config_test.go @@ -525,7 +525,7 @@ func TestValidateImageConfiguration(t *testing.T) { SkipIfOnlyChanged: `^docs/`, }, output: []error{ - errors.New("images: `run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, and `pipeline_skip_if_only_changed` are mutually exclusive"), + errors.New("images: `run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, `pipeline_skip_if_only_changed`, and `pipeline_run_if_dockerfile_changed` are mutually exclusive"), }, }, { name: "pipeline_run_if_changed and pipeline_skip_if_only_changed are mutually exclusive", @@ -534,7 +534,7 @@ func TestValidateImageConfiguration(t *testing.T) { PipelineSkipIfOnlyChanged: `^docs/`, }, output: []error{ - errors.New("images: `run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, and `pipeline_skip_if_only_changed` are mutually exclusive"), + errors.New("images: `run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, `pipeline_skip_if_only_changed`, and `pipeline_run_if_dockerfile_changed` are mutually exclusive"), }, }, { name: "run_if_changed with pipeline_skip_if_only_changed are mutually exclusive", @@ -543,7 +543,7 @@ func TestValidateImageConfiguration(t *testing.T) { PipelineSkipIfOnlyChanged: `^docs/`, }, output: []error{ - errors.New("images: `run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, and `pipeline_skip_if_only_changed` are mutually exclusive"), + errors.New("images: `run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, `pipeline_skip_if_only_changed`, and `pipeline_run_if_dockerfile_changed` are mutually exclusive"), }, }, { name: "run_if_changed alone is valid", diff --git a/pkg/validation/test.go b/pkg/validation/test.go index 605bab038f..23e4d60513 100644 --- a/pkg/validation/test.go +++ b/pkg/validation/test.go @@ -200,7 +200,7 @@ func (v *Validator) validateTestStepConfiguration( if (test.Cron != nil || test.Interval != nil || test.MinimumInterval != nil) && !test.Presubmit && (test.RunIfChanged != "" || test.SkipIfOnlyChanged != "" || test.Optional) { validationErrors = append(validationErrors, fmt.Errorf("%s: `cron`/`interval`/`minimum_interval` are mutually exclusive with `run_if_changed`/`skip_if_only_changed`/`optional`", fieldRootN)) } - if err := validateRunIfChangedExclusivity(test.RunIfChanged, test.SkipIfOnlyChanged, test.PipelineRunIfChanged, test.PipelineSkipIfOnlyChanged); err != nil { + if err := validateRunIfChangedExclusivity(test.RunIfChanged, test.SkipIfOnlyChanged, test.PipelineRunIfChanged, test.PipelineSkipIfOnlyChanged, test.PipelineRunIfDockerfileChanged); err != nil { validationErrors = append(validationErrors, fmt.Errorf("%s: %w", fieldRootN, err)) } diff --git a/pkg/validation/test_test.go b/pkg/validation/test_test.go index 6ee25f7f67..e3f0e87468 100644 --- a/pkg/validation/test_test.go +++ b/pkg/validation/test_test.go @@ -850,7 +850,28 @@ func TestValidateTests(t *testing.T) { SkipIfOnlyChanged: "^OTHER_README.md$", ContainerTestConfiguration: &api.ContainerTestConfiguration{From: "ignored"}, }}, - expectedError: errors.New("tests[0]: `run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, and `pipeline_skip_if_only_changed` are mutually exclusive"), + expectedError: errors.New("tests[0]: `run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, `pipeline_skip_if_only_changed`, and `pipeline_run_if_dockerfile_changed` are mutually exclusive"), + }, + { + id: "pipeline_run_if_dockerfile_changed with run_if_changed are mutually exclusive", + tests: []api.TestStepConfiguration{{ + As: "unit", + Commands: "commands", + RunIfChanged: "^README.md$", + PipelineRunIfDockerfileChanged: []api.DockerfileEntry{{Path: "Dockerfile"}}, + ContainerTestConfiguration: &api.ContainerTestConfiguration{From: "ignored"}, + }}, + expectedError: errors.New("tests[0]: `run_if_changed`, `skip_if_only_changed`, `pipeline_run_if_changed`, `pipeline_skip_if_only_changed`, and `pipeline_run_if_dockerfile_changed` are mutually exclusive"), + }, + { + id: "pipeline_run_if_dockerfile_changed with empty path", + tests: []api.TestStepConfiguration{{ + As: "unit", + Commands: "commands", + PipelineRunIfDockerfileChanged: []api.DockerfileEntry{{Path: ""}}, + ContainerTestConfiguration: &api.ContainerTestConfiguration{From: "ignored"}, + }}, + expectedError: errors.New("tests[0]: `pipeline_run_if_dockerfile_changed[0].path` must be set"), }, { id: "secrets used on multi-stage tests", diff --git a/pkg/webreg/zz_generated.ci_operator_reference.go b/pkg/webreg/zz_generated.ci_operator_reference.go index 09bf47dadc..666847deee 100644 --- a/pkg/webreg/zz_generated.ci_operator_reference.go +++ b/pkg/webreg/zz_generated.ci_operator_reference.go @@ -1090,6 +1090,17 @@ const ciOperatorReferenceYaml = "# The list of base images describe\n" + " # PipelineRunIfChanged is a regex that will result in the test only running in second\n" + " # stage of the pipeline run if something that matches it was changed.\n" + " pipeline_run_if_changed: ' '\n" + + " # PipelineRunIfDockerfileChanged determines whether to run a test based on\n" + + " # whether the container image built by the specified Dockerfile(s) would change.\n" + + " # Instead of hand-maintained regex, it resolves which files matter by parsing\n" + + " # Dockerfile COPY/ADD instructions.\n" + + " pipeline_run_if_dockerfile_changed:\n" + + " - # GoBinaryTargets is an optional list of Go build targets (e.g., \"./cmd/foo\").\n" + + " # When set, enables go-dependency-aware filtering for COPY-all Dockerfiles.\n" + + " go_binary_targets:\n" + + " - \"\"\n" + + " # Path is the path to the Dockerfile relative to the repo root.\n" + + " path: ' '\n" + " # PipelineSkipIfOnlyChanged is a regex that will result in the test being skipped in second\n" + " # stage of the pipeline run if all changed files match that regex.\n" + " pipeline_skip_if_only_changed: ' '\n" + @@ -2071,6 +2082,17 @@ const ciOperatorReferenceYaml = "# The list of base images describe\n" + " # PipelineRunIfChanged is a regex that will result in the test only running in second\n" + " # stage of the pipeline run if something that matches it was changed.\n" + " pipeline_run_if_changed: ' '\n" + + " # PipelineRunIfDockerfileChanged determines whether to run a test based on\n" + + " # whether the container image built by the specified Dockerfile(s) would change.\n" + + " # Instead of hand-maintained regex, it resolves which files matter by parsing\n" + + " # Dockerfile COPY/ADD instructions.\n" + + " pipeline_run_if_dockerfile_changed:\n" + + " - # GoBinaryTargets is an optional list of Go build targets (e.g., \"./cmd/foo\").\n" + + " # When set, enables go-dependency-aware filtering for COPY-all Dockerfiles.\n" + + " go_binary_targets:\n" + + " - \"\"\n" + + " # Path is the path to the Dockerfile relative to the repo root.\n" + + " path: ' '\n" + " # PipelineSkipIfOnlyChanged is a regex that will result in the test being skipped in second\n" + " # stage of the pipeline run if all changed files match that regex.\n" + " pipeline_skip_if_only_changed: ' '\n" +