From 7b6d7c790281afeaa6ee0f021cea10e37519a536 Mon Sep 17 00:00:00 2001 From: Piotr Rygielski <114479+vikin91@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:52:58 +0200 Subject: [PATCH 1/5] ROX-37371: Add Go CI selection resolver and dispatchers Keep the Python prototype's rule votes, defaults, dependency expansion, and dispatcher overrides while making the shell gates small. Use the root module's existing dependencies to expose bootstrap cost without adding a binary distribution system. Include Cobra help and resolver parity tests. AI-Assisted: Code partially generated by Codex. --- ci/decision-defaults | 33 ++ ci/select/.gitignore | 2 + ci/select/README.md | 192 +++++++++ ci/select/build.sh | 5 + ci/select/cli.go | 156 ++++++++ ci/select/cli_test.go | 242 ++++++++++++ ci/select/commands.go | 183 +++++++++ ci/select/config.go | 202 ++++++++++ ci/select/decision.go | 101 +++++ ci/select/decision_test.go | 62 +++ ci/select/gha-gate.sh | 11 + ci/select/gha.go | 219 +++++++++++ ci/select/gha_test.go | 107 +++++ ci/select/github.go | 132 +++++++ ci/select/main.go | 20 + ci/select/prow-gate.sh | 9 + ci/select/prow.go | 128 ++++++ ci/select/prow_test.go | 132 +++++++ ci/select/resolver.go | 335 ++++++++++++++++ ci/select/resolver_test.go | 317 +++++++++++++++ ci/select/testdata/corners-defaults | 5 + ci/select/testdata/corners.toml | 39 ++ ci/select/testdata/defaults | 23 ++ ci/select/testdata/defaults-go-postgres-skip | 23 ++ ci/select/testdata/edge_cases.json | 277 +++++++++++++ ci/select/testdata/invalid/bad-pattern.toml | 11 + ci/select/testdata/invalid/bad-version.toml | 6 + ci/select/testdata/invalid/broken.toml | 1 + ci/select/testdata/invalid/duplicate-job.toml | 6 + .../testdata/invalid/duplicate-rule-name.toml | 10 + .../testdata/invalid/jobs-not-a-list.toml | 2 + .../testdata/invalid/label-not-a-string.toml | 11 + .../testdata/invalid/label-not-allowed.toml | 11 + .../testdata/invalid/label-required.toml | 10 + .../invalid/last-rule-not-remaining.toml | 6 + .../testdata/invalid/last-rule-not-star.toml | 6 + ci/select/testdata/invalid/missing-name.toml | 6 + ci/select/testdata/invalid/no-jobs.toml | 6 + ci/select/testdata/invalid/no-opinion.toml | 9 + ci/select/testdata/invalid/no-rules.toml | 2 + .../testdata/invalid/paths-not-allowed.toml | 11 + .../testdata/invalid/paths-not-strings.toml | 11 + .../testdata/invalid/paths-required.toml | 10 + .../testdata/invalid/remaining-not-last.toml | 14 + ci/select/testdata/invalid/repeated-job.toml | 10 + .../testdata/invalid/requires-duplicate.toml | 8 + .../testdata/invalid/requires-empty.toml | 8 + .../invalid/requires-missing-target.toml | 8 + .../invalid/requires-not-a-table.toml | 8 + ci/select/testdata/invalid/requires-self.toml | 8 + .../invalid/requires-unknown-job.toml | 8 + .../testdata/invalid/rule-not-a-table.toml | 3 + .../testdata/invalid/run-not-a-list.toml | 10 + .../testdata/invalid/star-and-names.toml | 11 + .../testdata/invalid/star-mixed-in-list.toml | 10 + ci/select/testdata/invalid/star-twice.toml | 11 + ci/select/testdata/invalid/two-opinions.toml | 11 + ci/select/testdata/invalid/unknown-job.toml | 10 + ci/select/testdata/invalid/unknown-when.toml | 10 + ci/select/testdata/rejected_rules.json | 299 ++++++++++++++ ci/select/testdata/rules.toml | 120 ++++++ ci/test-domains.toml | 371 ++++++++++++++++++ 62 files changed, 4028 insertions(+) create mode 100644 ci/decision-defaults create mode 100644 ci/select/.gitignore create mode 100644 ci/select/README.md create mode 100755 ci/select/build.sh create mode 100644 ci/select/cli.go create mode 100644 ci/select/cli_test.go create mode 100644 ci/select/commands.go create mode 100644 ci/select/config.go create mode 100644 ci/select/decision.go create mode 100644 ci/select/decision_test.go create mode 100755 ci/select/gha-gate.sh create mode 100644 ci/select/gha.go create mode 100644 ci/select/gha_test.go create mode 100644 ci/select/github.go create mode 100644 ci/select/main.go create mode 100755 ci/select/prow-gate.sh create mode 100644 ci/select/prow.go create mode 100644 ci/select/prow_test.go create mode 100644 ci/select/resolver.go create mode 100644 ci/select/resolver_test.go create mode 100644 ci/select/testdata/corners-defaults create mode 100644 ci/select/testdata/corners.toml create mode 100644 ci/select/testdata/defaults create mode 100644 ci/select/testdata/defaults-go-postgres-skip create mode 100644 ci/select/testdata/edge_cases.json create mode 100644 ci/select/testdata/invalid/bad-pattern.toml create mode 100644 ci/select/testdata/invalid/bad-version.toml create mode 100644 ci/select/testdata/invalid/broken.toml create mode 100644 ci/select/testdata/invalid/duplicate-job.toml create mode 100644 ci/select/testdata/invalid/duplicate-rule-name.toml create mode 100644 ci/select/testdata/invalid/jobs-not-a-list.toml create mode 100644 ci/select/testdata/invalid/label-not-a-string.toml create mode 100644 ci/select/testdata/invalid/label-not-allowed.toml create mode 100644 ci/select/testdata/invalid/label-required.toml create mode 100644 ci/select/testdata/invalid/last-rule-not-remaining.toml create mode 100644 ci/select/testdata/invalid/last-rule-not-star.toml create mode 100644 ci/select/testdata/invalid/missing-name.toml create mode 100644 ci/select/testdata/invalid/no-jobs.toml create mode 100644 ci/select/testdata/invalid/no-opinion.toml create mode 100644 ci/select/testdata/invalid/no-rules.toml create mode 100644 ci/select/testdata/invalid/paths-not-allowed.toml create mode 100644 ci/select/testdata/invalid/paths-not-strings.toml create mode 100644 ci/select/testdata/invalid/paths-required.toml create mode 100644 ci/select/testdata/invalid/remaining-not-last.toml create mode 100644 ci/select/testdata/invalid/repeated-job.toml create mode 100644 ci/select/testdata/invalid/requires-duplicate.toml create mode 100644 ci/select/testdata/invalid/requires-empty.toml create mode 100644 ci/select/testdata/invalid/requires-missing-target.toml create mode 100644 ci/select/testdata/invalid/requires-not-a-table.toml create mode 100644 ci/select/testdata/invalid/requires-self.toml create mode 100644 ci/select/testdata/invalid/requires-unknown-job.toml create mode 100644 ci/select/testdata/invalid/rule-not-a-table.toml create mode 100644 ci/select/testdata/invalid/run-not-a-list.toml create mode 100644 ci/select/testdata/invalid/star-and-names.toml create mode 100644 ci/select/testdata/invalid/star-mixed-in-list.toml create mode 100644 ci/select/testdata/invalid/star-twice.toml create mode 100644 ci/select/testdata/invalid/two-opinions.toml create mode 100644 ci/select/testdata/invalid/unknown-job.toml create mode 100644 ci/select/testdata/invalid/unknown-when.toml create mode 100644 ci/select/testdata/rejected_rules.json create mode 100644 ci/select/testdata/rules.toml create mode 100644 ci/test-domains.toml diff --git a/ci/decision-defaults b/ci/decision-defaults new file mode 100644 index 0000000000000..44005b969a572 --- /dev/null +++ b/ci/decision-defaults @@ -0,0 +1,33 @@ +# One target per line. The last word is run or skip. +# The resolver uses this line when the catch-all rule says default. +# The dispatcher uses the whole file when the resolver fails. +# These lines are a ready pull request: not a draft, not a fork, and no +# opt-in label. A change that is only under ui/ is a separate filter. + +style-check run +go run +go-postgres run +sensor-integration-tests run +should-dispatch run +wait-for-images run +e2e-byodb-tests run +e2e-db-backup-restore-tests run +e2e-gke-upgrade-tests run +e2e-nongroovy-tests run +e2e-qa-tests-gke skip +e2e-qa-tests-gke-konflux skip +e2e-qa-tests-ocp-4-22 skip +gke-nongroovy-e2e-tests run +gke-qa-e2e-tests skip +gke-ui-e2e-tests run +ocp-vm-scanning-e2e-tests skip +check-generated-files run +check-crd-compatibility run +golangci-lint run +github-actions-lint run +openshift-ci-lint run +shell-unit-tests run +ui run +ui-component run +local-roxctl-tests run +db-integration-tests run diff --git a/ci/select/.gitignore b/ci/select/.gitignore new file mode 100644 index 0000000000000..f098703f25f3c --- /dev/null +++ b/ci/select/.gitignore @@ -0,0 +1,2 @@ +bin/ +__pycache__/ diff --git a/ci/select/README.md b/ci/select/README.md new file mode 100644 index 0000000000000..0c1e22c3ff853 --- /dev/null +++ b/ci/select/README.md @@ -0,0 +1,192 @@ +# CI test selection prototype + +The Go selector decides which pull-request jobs to run. GitHub Actions and +Prow dispatchers apply that decision. The label `ci-dispatcher-enforce` +enables selection; without it, the gates run jobs and report the plan. + +This is a port of the implementation at StackRox PR #23166, commit +`5457183f73f67bfe6f7e13d591916b8ea1703a46`. It keeps that implementation's +sample rules, defaults, and enforcement behavior. The PR description and +some Python tests describe earlier policies; the implementation and the +fixture cases define this prototype's behavior. + +## Build and test + +Run from the repository root with the Go version in `go.mod`: + +```sh +ci/select/build.sh +go test -race ./ci/select +ci/select/bin/ci-select validate +``` + +The build compiles only `ci/select`; it does not build Central, Sensor, or +roxctl. The executable is ignored by Git. The root module already provides +the TOML, error-wrapping, and test dependencies. Python, `jq`, and `curl` are +not required by the executable. Git is required to collect CI diffs and +Prow's head commit timestamp. + +Run `ci/select/bin/ci-select` with no arguments for a command overview. +Every command accepts `-h` and `--help`, with flags and examples for that +command. For example, `ci/select/bin/ci-select help prow gate` explains the +local Prow gate, and `ci/select/bin/ci-select prow-ci -h` describes its CI +environment, stdin, and exit statuses. + +## Try the resolver locally + +Create a changed-path list and a label list, one item per line. For example, +use a file containing `README.md` and an empty labels file: + +```sh +ci/select/bin/ci-select resolve \ + --files-file files.txt --labels-file labels.txt \ + --repo stackrox/stackrox --pr 23166 --commit a1b2c3d \ + --decision-out decision.txt --log-out resolver.log +``` + +The default configuration paths are `ci/test-domains.toml` and +`ci/decision-defaults`. Override them with `--rules` and `--defaults`. +Without `--decision-out`, the resolver writes the decision to stdout. + +The decision lists jobs to run, one per line. An absent job is skipped. +`# no job runs` represents a valid decision to run nothing; blank text is +not a usable decision. Standalone comments are accepted, but inline +comments are rejected. The explanation log includes the commit, time, +matched rules, conflicts, defaults, and added dependencies. + +## Rule behavior + +The resolver evaluates rules in file order. Each matched rule gives its +named jobs a run, skip, or default vote. `*` names every job. A run vote +wins a conflict with a skip vote. A default is used only when there is no +run or skip vote. The final `remaining` rule covers jobs with no earlier +vote; it must use `*` for exactly one action. + +The supported conditions are: + +- `any-file-matches`: at least one changed path matches at least one pattern. +- `every-file-matches`: every changed path matches a pattern, and the diff is nonempty. +- `no-file-changed`: the diff is empty. +- `label-exists`: the configured label is present. +- `always`: the rule applies regardless of files and labels. +- `remaining`: only the last rule may use it. + +Patterns use Go's `regexp` syntax. The checked-in patterns are supported; +Python-specific lookarounds and backreferences are not supported. Invalid +patterns are rejected rather than translated. + +A running job pulls in its `requires` dependencies, even if their own votes +said skip. Cycles, self-dependencies, unknown jobs, duplicate names, +overlapping actions within a rule, and mismatched defaults are rejected. +Job and explanation ordering follow the configuration rather than Go map +iteration order. + +An empty diff is distinct from a diff that could not be read. The latter +uses the defaults. Dispatchers also use defaults when rules cannot be +loaded or resolved. If defaults are unusable, individual CI gates let the +job run. Jobs absent from the defaults are outside selection and run. + +## Try the dispatchers locally + +The local dispatcher commands take the same file inputs as the resolver: + +```sh +ci/select/bin/ci-select gha gate --job go \ + --files-file files.txt --labels-file labels.txt --enforce + +ci/select/bin/ci-select gha print \ + --files-file files.txt --labels-file labels.txt + +ci/select/bin/ci-select prow gate --job gke-qa-e2e-tests \ + --files-file files.txt --labels-file labels.txt --enforce \ + --comments-file comments.json --commit-time 2026-09-30T11:00:00Z +``` + +`gate` writes `run` or `skip` to stdout and explanations to stderr. Omit +`--enforce` to run the job while reporting the planned action. Use +`--files-unknown` instead of `--files-file` to exercise the defaults path. + +The comments file is a JSON array of objects with `created_at` and `body`. +A `/test JOB` or `/test all` line at or after the PR head's committer time +forces that Prow job to run. Older or malformed timestamps do not force a +job. The CI adapter retrieves every comment page; an API failure lets the +job run so it cannot hide a `/test` request. + +## CI entry points + +`gha-gate.sh` calls `ci-select gha-ci`. Go collects the diff, parses labels, +resolves the decision, and writes `action=run` or `action=skip` to +`GITHUB_OUTPUT`. With no job argument it prints the plan and appends it to +`GITHUB_STEP_SUMMARY`. The wrapper's only fallback is `action=run` when the +binary is missing or fails. + +The GHA gate reads `CI_BASE`, `CI_HEAD`, `CI_REPO`, `CI_PR`, `CI_COMMIT`, +`PR_LABELS` (comma-separated), and `CI_ENFORCE` (`true` enables selection). +`CI_DECISION_OUT` optionally saves the decision. `CI_SELECT_ROOT` locates +the checkout; the wrapper sets it automatically. + +The `should-dispatch` job calls `ci-select gha-dispatch-ci`, replacing the +inline Python dispatcher. Go retrieves current PR labels and writes the +job map plus `wait-for-gha-images` and `wait-for-konflux-images` outputs. +It reads `GITHUB_EVENT_NAME`, the metadata variables listed earlier, and +`CI_IS_FORK`, `CI_IS_DRAFT`, and `CI_UI_ONLY`. Fork rejection is preserved. +With enforcement disabled, non-PR events, or unusable defaults, it follows +the existing dispatcher rules. Failure to retrieve live labels fails this +dispatch step, as the original dispatcher did. + +`prow-gate.sh` defines `ci_decision_allows`. It pipes the existing CI +library's `get_pr_details` JSON into `ci-select prow-ci JOB`. Go reads the +Prow environment, fetches comments, handles rehearsal labels in the PR +body, and resolves the decision. Only exit code 3 means skip; success and +errors let the job run. The wrapper does not build the binary, parse JSON, +fetch comments, or implement selection policy. + +Prow uses `PULL_BASE_SHA` for the diff against `HEAD` and `PULL_PULL_SHA` for +the head commit timestamp. The synthetic merge timestamp is not used for +`/test` freshness. The GitHub client uses `GITHUB_TOKEN` or `GH_TOKEN`, and +`GITHUB_API_URL` when set. Requests have a 30-second timeout, up to three +attempts for transport/server failures, and pagination for labels/comments. + +## Bootstrap and prototype limits + +GHA uses `.github/actions/ci-select` to install the Go version from `go.mod` +and build the binary before the gate. Individual gated jobs tolerate build +failure and proceed with their normal work. The decision and e2e dispatch +workflows require a successful build. Prow builds once at dispatch entry; +build failure bypasses selection and runs the job. + +These integration costs still need validation in real CI: + +- Cold jobs may download a Go toolchain and module dependencies before they + can skip. The prototype uses the root module and adds no binary-distribution + or cross-workflow artifact system. +- Prow images need a usable Go compiler and access to required downloads. + A build failure is visible in logs but means that job will not be skipped. +- GHA's `should-dispatch` uses `ubuntu-24.04` instead of `ubuntu-slim` to + support the shared toolchain setup. +- Skipped GHA jobs still allocate runners and check out the repository. + Existing outer workflow conditions remain in effect. Prow setup performed + before `dispatch.sh` remains outside this gate. +- The rules and job inventory are samples, not a reviewed production map. + In particular, the sample OCP target name and the dispatched Konflux target + name differ; an unenrolled target follows existing dispatch rules. +- `image-wait` still requests image waiting on every PR. A language port + does not make those waits cheaper or change required-check behavior. + +The Go tests retain the 18 resolver fixture cases and 33 invalid-rule cases +from the Python prototype. They compare decisions and explanation logs +(excluding the timestamp) and the complete selected-job set. The fixture +loaders check counts and unique names so a case cannot disappear silently. +Rejected-rule tests check the rejection reason; typed TOML decoding errors +must identify the offending field. + +Named resolver tests also check the design example, mixed CI/product changes, +unmatched paths, dependencies overriding skips, fallback defaults, invalid +configuration, and unusable defaults. CLI tests check empty diffs, both output +files, failure exit statuses, and the absence of output files on input errors, +including execution of the compiled binary. + +The suite also covers current sample rules, dispatcher +fallbacks, fork rejection, `/test` timing, HTTP pagination, CLI outputs, git +diffs, and wrapper exit behavior. They do not contact GitHub or create CI +clusters. diff --git a/ci/select/build.sh b/ci/select/build.sh new file mode 100755 index 0000000000000..03c3cf67a20b1 --- /dev/null +++ b/ci/select/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$root" +go build -trimpath -o ci/select/bin/ci-select ./ci/select diff --git a/ci/select/cli.go b/ci/select/cli.go new file mode 100644 index 0000000000000..cc82070fa7a49 --- /dev/null +++ b/ci/select/cli.go @@ -0,0 +1,156 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +type options struct { + rules, defaults, files, labels, comments string + repo, pr, commit, commitTime, job string + decisionOut, logOut string + unknown, enforce bool +} + +func runLocal(command, mode string, o options, output, diagnostic io.Writer) error { + if mode != "" && mode != "gate" && !(command == "gha" && mode == "print") { + return fmt.Errorf("unknown %s mode %q", command, mode) + } + if mode == "gate" && o.job == "" { + return fmt.Errorf("gate needs --job") + } + if o.unknown == (o.files != "") { + return fmt.Errorf("pass either --files-file or --files-unknown") + } + files := []string{} + if !o.unknown { + text, err := os.ReadFile(o.files) + if err != nil { + return errors.Wrap(err, "read changed files") + } + files = readList(string(text)) + } + labels := []string{} + if o.labels != "" { + text, err := os.ReadFile(o.labels) + if err != nil { + return errors.Wrap(err, "read labels") + } + labels = readList(string(text)) + } + meta := metadata{repo: o.repo, pr: o.pr, commit: o.commit} + var resolved result + var d defaults + var err error + if command == "resolve" { + if o.unknown { + return fmt.Errorf("changed files are unknown") + } + m, loadErr := loadMapping(o.rules) + if loadErr != nil { + return loadErr + } + text, readErr := os.ReadFile(o.defaults) + if readErr != nil { + return errors.Wrap(readErr, "read decision-defaults") + } + d, err = parseDefaults(string(text)) + if err != nil { + return err + } + resolved, err = resolve(files, labels, m, d, meta) + } else { + resolved, d, err = openDecision(o.rules, o.defaults, files, labels, o.unknown, meta) + } + if err != nil { + return err + } + if o.decisionOut != "" { + if err := os.WriteFile(o.decisionOut, []byte(resolved.decision), 0o600); err != nil { + return errors.Wrap(err, "write decision file") + } + } + if o.logOut != "" { + if err := os.WriteFile(o.logOut, []byte(resolved.log), 0o600); err != nil { + return errors.Wrap(err, "write resolver log") + } + } + if command == "resolve" { + if o.decisionOut == "" { + _, err = io.WriteString(output, resolved.decision) + } + return errors.Wrap(err, "print decision") + } + if mode == "print" { + return printPlan(resolved, o.enforce, output) + } + forced := false + if command == "prow" && o.comments != "" { + comments, err := readComments(o.comments) + if err != nil { + return err + } + forced = requestsSince(comments, o.job, o.commitTime) + } + if _, err := io.WriteString(diagnostic, resolved.log); err != nil { + return errors.Wrap(err, "print resolver log") + } + action := gateAction(o.job, resolved.jobs, d, o.enforce, forced) + if !o.enforce { + fmt.Fprintf(diagnostic, "%s: decision would %s; label ci-dispatcher-enforce is absent so the job runs\n", + o.job, gateAction(o.job, resolved.jobs, d, true, forced)) + } + _, err = fmt.Fprintln(output, action) + return errors.Wrap(err, "print gate action") +} + +func validateRules(o options, output io.Writer) error { + m, err := loadMapping(o.rules) + if err != nil { + return err + } + text, err := os.ReadFile(o.defaults) + if err != nil { + return errors.Wrap(err, "read decision-defaults") + } + d, err := parseDefaults(string(text)) + if err != nil { + return err + } + if _, err := resolve(nil, nil, m, d, metadata{}); err != nil { + return err + } + mentioned := map[string]bool{} + for _, r := range m.Rules { + for _, names := range [][]string{r.Run, r.Skip, r.Default} { + for _, job := range names { + mentioned[job] = true + } + } + } + for _, job := range m.Jobs { + if !mentioned[job] { + fmt.Fprintf(output, "WARNING: %s has no explicit job rule\n", job) + } + } + _, err = fmt.Fprintln(output, "Validation passed") + return errors.Wrap(err, "print validation result") +} + +func rootDir() string { + if root := os.Getenv("CI_SELECT_ROOT"); root != "" { + return root + } + if root := os.Getenv("ROOT"); root != "" { + return root + } + return "." +} + +func rulePaths() (string, string) { + return filepath.Join(rootDir(), "ci/test-domains.toml"), filepath.Join(rootDir(), "ci/decision-defaults") +} diff --git a/ci/select/cli_test.go b/ci/select/cli_test.go new file mode 100644 index 0000000000000..be14cc302c76c --- /dev/null +++ b/ci/select/cli_test.go @@ -0,0 +1,242 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTestFile(t *testing.T, path, text string, mode os.FileMode) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(text), mode)) +} + +func TestResolveCLI(t *testing.T) { + root := t.TempDir() + files, labels := filepath.Join(root, "files"), filepath.Join(root, "labels") + writeTestFile(t, files, "README.md\n", 0o600) + writeTestFile(t, labels, "", 0o600) + decision, log := filepath.Join(root, "decision"), filepath.Join(root, "log") + args := []string{"resolve", "--rules", "testdata/rules.toml", "--defaults", "testdata/defaults", + "--files-file", files, "--labels-file", labels, "--decision-out", decision, "--log-out", log} + code, err := run(t.Context(), args, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + require.NoError(t, err) + assert.Zero(t, code) + text, err := os.ReadFile(decision) + require.NoError(t, err) + assert.Equal(t, "style-check\nwait-for-images\nshould-dispatch\n", string(text)) + text, err = os.ReadFile(log) + require.NoError(t, err) + assert.Contains(t, string(text), "requires it") + args[2] = "testdata/invalid/bad-version.toml" + code, err = run(t.Context(), args, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + require.Error(t, err) + assert.NotZero(t, code) +} + +func TestResolveCLIEmptyDiffAndFailures(t *testing.T) { + inputs := t.TempDir() + files := filepath.Join(inputs, "files") + labels := filepath.Join(inputs, "labels") + writeTestFile(t, files, "", 0o600) + writeTestFile(t, labels, "", 0o600) + for name, test := range map[string]struct { + rules, defaults, files string + expected, errorText string + }{ + "empty known diff": { + rules: "testdata/corners.toml", defaults: "testdata/corners-defaults", files: files, expected: "unit\n", + }, + "invalid rules": { + rules: "testdata/invalid/bad-version.toml", defaults: "testdata/corners-defaults", files: files, + errorText: "unsupported rules version: 2", + }, + "broken rules": { + rules: "testdata/invalid/broken.toml", defaults: "testdata/corners-defaults", files: files, + errorText: "read TOML rules", + }, + "missing changed files": { + rules: "testdata/corners.toml", defaults: "testdata/corners-defaults", files: filepath.Join(inputs, "missing"), + errorText: "read changed files", + }, + "invalid defaults": { + rules: "testdata/corners.toml", defaults: fixturePath(t, "unit maybe\n"), files: files, + errorText: "decision must be run or skip", + }, + "missing defaults": { + rules: "testdata/corners.toml", defaults: filepath.Join(inputs, "missing"), files: files, + errorText: "read decision-defaults", + }, + } { + t.Run(name, func(t *testing.T) { + folder := t.TempDir() + decision, log := filepath.Join(folder, "decision"), filepath.Join(folder, "log") + var output, diagnostic bytes.Buffer + code, err := run(t.Context(), []string{ + "resolve", "--rules", test.rules, "--defaults", test.defaults, + "--files-file", test.files, "--labels-file", labels, + "--decision-out", decision, "--log-out", log, + "--repo", "stackrox/stackrox", "--pr", "23035", "--commit", "a1b2c3d", + }, strings.NewReader(""), &output, &diagnostic) + if test.errorText != "" { + require.ErrorContains(t, err, test.errorText) + assert.Equal(t, 1, code) + _, err = os.Stat(decision) + assert.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(log) + assert.ErrorIs(t, err, os.ErrNotExist) + return + } + require.NoError(t, err) + assert.Zero(t, code) + text, err := os.ReadFile(decision) + require.NoError(t, err) + assert.Equal(t, test.expected, string(text)) + text, err = os.ReadFile(log) + require.NoError(t, err) + assert.Contains(t, string(text), "# stackrox/stackrox PR 23035 a1b2c3d") + assert.Contains(t, string(text), "nothing-changed") + assert.Empty(t, output.String()) + }) + } +} + +func TestResolveCLIProcess(t *testing.T) { + root := t.TempDir() + binary := filepath.Join(root, "ci-select") + build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, ".") + text, err := build.CombinedOutput() + require.NoError(t, err, string(text)) + files := filepath.Join(root, "files") + writeTestFile(t, files, "README.md\n", 0o600) + for name, test := range map[string]struct { + rules string + fail bool + }{ + "success": {rules: "testdata/rules.toml"}, + "invalid rules": {rules: "testdata/invalid/bad-version.toml", fail: true}, + } { + t.Run(name, func(t *testing.T) { + folder := t.TempDir() + decision, log := filepath.Join(folder, "decision"), filepath.Join(folder, "log") + var output, diagnostic bytes.Buffer + command := exec.CommandContext(t.Context(), binary, + "resolve", "--rules", test.rules, "--defaults", "testdata/defaults", "--files-file", files, + "--decision-out", decision, "--log-out", log, "--pr", "23035") + command.Stdout, command.Stderr = &output, &diagnostic + err := command.Run() + if test.fail { + require.Error(t, err) + var exit *exec.ExitError + require.ErrorAs(t, err, &exit) + assert.Equal(t, 1, exit.ExitCode()) + assert.Contains(t, diagnostic.String(), "ci-select:") + assert.Contains(t, diagnostic.String(), "unsupported rules version: 2") + _, err = os.Stat(decision) + assert.ErrorIs(t, err, os.ErrNotExist) + return + } + require.NoError(t, err, diagnostic.String()) + assert.Empty(t, diagnostic.String()) + text, err := os.ReadFile(decision) + require.NoError(t, err) + assert.Equal(t, "style-check\nwait-for-images\nshould-dispatch\n", string(text)) + text, err = os.ReadFile(log) + require.NoError(t, err) + assert.Contains(t, string(text), "PR 23035") + }) + } +} + +func TestCLIValidation(t *testing.T) { + for name, args := range map[string][]string{ + "unknown command": {"bad"}, + "unknown mode": {"gha", "bad", "--files-unknown"}, + "missing job": {"gha", "gate", "--files-unknown"}, + "both diff options": {"gha", "print", "--files-file", "file", "--files-unknown"}, + "missing diff": {"gha", "print"}, "extra argument": {"validate", "extra"}, + } { + t.Run(name, func(t *testing.T) { + code, err := run(t.Context(), args, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + require.Error(t, err) + assert.NotZero(t, code) + }) + } +} + +func TestCLIHelp(t *testing.T) { + commands := map[string][]string{ + "root": {}, "resolve": {"resolve"}, "validate": {"validate"}, + "gha": {"gha"}, "gha print": {"gha", "print"}, "gha gate": {"gha", "gate"}, + "prow": {"prow"}, "prow gate": {"prow", "gate"}, + "gha-ci": {"gha-ci"}, "gha-dispatch-ci": {"gha-dispatch-ci"}, "prow-ci": {"prow-ci"}, + } + for name, command := range commands { + for _, flag := range []string{"-h", "--help"} { + t.Run(name+" "+flag, func(t *testing.T) { + var output, diagnostic bytes.Buffer + args := append(append([]string{}, command...), flag) + code, err := run(t.Context(), args, strings.NewReader(""), &output, &diagnostic) + require.NoError(t, err) + assert.Zero(t, code) + assert.Empty(t, diagnostic.String()) + assert.Contains(t, output.String(), "Usage:") + assert.Contains(t, output.String(), "Flags:") + assert.Contains(t, output.String(), "-h, --help") + }) + } + } +} + +func TestCLIHelpContent(t *testing.T) { + for name, test := range map[string]struct { + args []string + contains, absent []string + }{ + "no command shows overview": { + contains: []string{"Available Commands:", "Examples:", "Select CI jobs", "gha-dispatch-ci", "prow-ci"}, + }, + "group shows subcommands": {args: []string{"gha"}, contains: []string{"gate", "print"}}, + "help command": {args: []string{"help", "resolve"}, contains: []string{"--files-file", "--log-out", "Examples:"}}, + "validate flags": {args: []string{"validate", "-h"}, contains: []string{"--rules", "--defaults"}, absent: []string{"--comments-file", "--enforce"}}, + "GHA gate flags": {args: []string{"gha", "gate", "-h"}, contains: []string{"--job", "--enforce", "--files-unknown"}, absent: []string{"--comments-file"}}, + "Prow local help": {args: []string{"prow", "gate", "-h"}, contains: []string{"--comments-file", "--commit-time", "/test all"}}, + "Prow CI contract": {args: []string{"prow-ci", "-h"}, contains: []string{"stdin", "PULL_PULL_SHA", "3 means skip"}}, + "GHA CI contract": {args: []string{"gha-ci", "-h"}, contains: []string{"GITHUB_OUTPUT", "CI_ENFORCE=true"}}, + } { + t.Run(name, func(t *testing.T) { + var output, diagnostic bytes.Buffer + code, err := run(t.Context(), test.args, strings.NewReader(""), &output, &diagnostic) + require.NoError(t, err) + assert.Zero(t, code) + assert.Empty(t, diagnostic.String()) + for _, text := range test.contains { + assert.Contains(t, output.String(), text) + } + for _, text := range test.absent { + assert.NotContains(t, output.String(), text) + } + }) + } +} + +func TestGHAWrapperBuildFailure(t *testing.T) { + output := filepath.Join(t.TempDir(), "output") + // The override exercises the wrapper fallback without depending on a local build. + text, err := os.ReadFile("gha-gate.sh") + require.NoError(t, err) + script := strings.Replace(string(text), `"$root/ci/select/bin/ci-select"`, `"/missing-ci-selector"`, 1) + command := exec.CommandContext(t.Context(), "bash", "-c", script) + command.Env = append(command.Environ(), "GITHUB_OUTPUT="+output) + require.NoError(t, command.Run()) + text, err = os.ReadFile(output) + require.NoError(t, err) + assert.Equal(t, "action=run\n", string(text)) +} diff --git a/ci/select/commands.go b/ci/select/commands.go new file mode 100644 index 0000000000000..93d6766b333e5 --- /dev/null +++ b/ci/select/commands.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "io" + + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func run(ctx context.Context, args []string, input io.Reader, output, diagnostic io.Writer) (int, error) { + code := 2 + command := newCommand(&code) + command.SetArgs(args) + command.SetIn(input) + command.SetOut(output) + command.SetErr(diagnostic) + if err := command.ExecuteContext(ctx); err != nil { + return code, errors.Wrap(err, "execute command") + } + if code == 3 { + return code, nil + } + return 0, nil +} + +func newCommand(code *int) *cobra.Command { + root := &cobra.Command{ + Use: "ci-select", + Short: "Select and gate StackRox CI jobs for a pull request", + Long: `Select CI jobs from changed paths, pull-request labels, TOML rules, and job defaults. + +Use resolve, validate, gha, and prow with local input files to inspect a plan. +The *-ci commands read the CI environment and integrate with GitHub Actions or Prow. +Without enforcement, dispatchers report the planned decision and let jobs run.`, + Example: ` ci-select resolve --files-file files.txt --labels-file labels.txt + ci-select gha gate --job go --files-file files.txt --enforce + ci-select validate + ci-select help prow-ci`, + Args: cobra.NoArgs, + RunE: commandHelp, + SilenceUsage: true, + SilenceErrors: true, + } + root.CompletionOptions.DisableDefaultCmd = true + root.AddCommand(localCommand("resolve", "", code), localCommand("validate", "", code)) + gha := &cobra.Command{ + Use: "gha", Short: "Inspect or gate GitHub Actions jobs using local inputs", + Args: cobra.NoArgs, RunE: commandHelp, + } + gha.AddCommand(localCommand("gha", "print", code), localCommand("gha", "gate", code)) + prow := &cobra.Command{ + Use: "prow", Short: "Gate Prow jobs using local inputs and /test comments", + Args: cobra.NoArgs, RunE: commandHelp, + } + prow.AddCommand(localCommand("prow", "gate", code)) + root.AddCommand(gha, prow) + root.AddCommand(&cobra.Command{ + Use: "gha-ci [job]", + Short: "Print a plan or gate a job from the GitHub Actions environment", + Long: `Read CI_BASE, CI_HEAD, PR_LABELS, CI_REPO, CI_PR, and CI_COMMIT. +With a job argument, write action=run or action=skip to GITHUB_OUTPUT. +With no job argument, print the plan and append it to GITHUB_STEP_SUMMARY. +CI_ENFORCE=true applies the decision; otherwise the job runs. +CI_DECISION_OUT optionally saves the decision file.`, + Example: " ci-select gha-ci\n ci-select gha-ci go", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + *code = 1 + return ghaCI(cmd.Context(), args, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + }) + root.AddCommand(&cobra.Command{ + Use: "gha-dispatch-ci", + Short: "Write the end-to-end job map and image-wait outputs for GitHub Actions", + Long: `Read GITHUB_EVENT_NAME, CI_REPO, CI_PR, CI_BASE, CI_HEAD, CI_COMMIT, +CI_IS_FORK, CI_IS_DRAFT, and CI_UI_ONLY. Retrieve current PR labels from GitHub. +Write jobs, wait-for-gha-images, and wait-for-konflux-images to GITHUB_OUTPUT. +The ci-dispatcher-enforce label applies selection; fork rejection remains in effect. +GitHub requests use GITHUB_TOKEN or GH_TOKEN.`, + Example: " ci-select gha-dispatch-ci", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + *code = 1 + return ghaDispatchCI(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + }) + root.AddCommand(&cobra.Command{ + Use: "prow-ci ", + Short: "Gate a Prow job using PR JSON on stdin and the Prow environment", + Long: `Read PR details as JSON from stdin, PULL_BASE_SHA for the diff, and +PULL_PULL_SHA for the head commit timestamp. Retrieve /test comments from GitHub. +The ci-dispatcher-enforce label enables selection. A current /test request forces run. +Exit status 0 means run, 3 means skip, and other nonzero statuses mean an error. +The bash wrapper lets the job run on errors. GitHub requests use GITHUB_TOKEN or GH_TOKEN.`, + Example: " get_pr_details | ci-select prow-ci gke-qa-e2e-tests", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + *code = 1 + action, err := prowCI(cmd.Context(), args[0], cmd.InOrStdin(), cmd.ErrOrStderr()) + if err != nil { + return err + } + *code = 0 + if action == "skip" { + *code = 3 + } + return nil + }, + }) + return root +} + +func commandHelp(cmd *cobra.Command, _ []string) error { + return errors.Wrap(cmd.Help(), "show help") +} + +func localCommand(command, mode string, code *int) *cobra.Command { + var o options + cmd := &cobra.Command{Use: command, Args: cobra.NoArgs} + if mode != "" { + cmd.Use = mode + } + switch command + " " + mode { + case "resolve ": + cmd.Short = "Resolve changed paths and labels into a list of jobs to run" + cmd.Long = `Read changed paths and labels, one per line, and evaluate the rules. +Write the decision to stdout unless --decision-out is set. +Use --log-out to save the explanation. An empty files list means no files changed.` + cmd.Example = " ci-select resolve --files-file files.txt --labels-file labels.txt --decision-out decision.txt --log-out resolver.log" + case "validate ": + cmd.Short = "Validate the rules, job defaults, and dependency graph" + cmd.Example = " ci-select validate\n ci-select validate --rules ci/select/testdata/rules.toml --defaults ci/select/testdata/defaults" + case "gha print": + cmd.Short = "Print the decision and its explanation using local input files" + cmd.Example = " ci-select gha print --files-file files.txt --labels-file labels.txt" + case "gha gate", "prow gate": + cmd.Short = "Print run or skip for one job using local input files" + cmd.Long = `Resolve the local inputs and write run or skip to stdout, with explanations on stderr. +Without --enforce, the job runs and the planned action is logged. +If the diff is unavailable, use --files-unknown to fall back to job defaults.` + cmd.Example = " ci-select " + command + " gate --job go --files-file files.txt --labels-file labels.txt --enforce" + } + flags := cmd.Flags() + flags.StringVar(&o.rules, "rules", "ci/test-domains.toml", "Path to the TOML selection rules") + flags.StringVar(&o.defaults, "defaults", "ci/decision-defaults", "Path to the per-job run/skip defaults") + if command != "validate" { + flags.StringVar(&o.files, "files-file", "", "Changed paths file, one path per line") + flags.StringVar(&o.labels, "labels-file", "", "Labels file, one label per line; omit for no labels") + flags.StringVar(&o.repo, "repo", "stackrox/stackrox", "Repository owner/name for the log") + flags.StringVar(&o.pr, "pr", "0", "Pull request number for the log") + flags.StringVar(&o.commit, "commit", "unknown", "Head commit SHA for the log") + flags.StringVar(&o.decisionOut, "decision-out", "", "Save the decision to this file") + flags.StringVar(&o.logOut, "log-out", "", "Save the resolver explanation to this file") + if command == "resolve" { + cmd.MarkFlagsOneRequired("files-file") + } else { + flags.BoolVar(&o.unknown, "files-unknown", false, "Diff is unavailable; use job defaults") + flags.BoolVar(&o.enforce, "enforce", false, "Apply the decision instead of running every job") + cmd.MarkFlagsOneRequired("files-file", "files-unknown") + cmd.MarkFlagsMutuallyExclusive("files-file", "files-unknown") + } + } + if mode == "gate" { + flags.StringVar(&o.job, "job", "", "Name of the CI job to gate (required)") + cmd.MarkFlagsOneRequired("job") + } + if command == "prow" { + flags.StringVar(&o.comments, "comments-file", "", "JSON comment array with body and created_at fields") + flags.StringVar(&o.commitTime, "commit-time", "", "Head commit RFC3339 timestamp for /test freshness") + cmd.Long += "\nA /test JOB or /test all comment at or after --commit-time forces the job to run." + cmd.Example = ` ci-select prow gate --job gke-qa-e2e-tests --files-file files.txt --enforce \ + --comments-file comments.json --commit-time 2026-09-30T11:00:00Z` + } + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + *code = 1 + if command == "validate" { + return validateRules(o, cmd.OutOrStdout()) + } + return runLocal(command, mode, o, cmd.OutOrStdout(), cmd.ErrOrStderr()) + } + return cmd +} diff --git a/ci/select/config.go b/ci/select/config.go new file mode 100644 index 0000000000000..8861ef12fd682 --- /dev/null +++ b/ci/select/config.go @@ -0,0 +1,202 @@ +package main + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "github.com/BurntSushi/toml" + "github.com/pkg/errors" +) + +type rule struct { + Name string `toml:"name"` + When string `toml:"when"` + Paths []string `toml:"paths"` + Label string `toml:"label"` + Run []string `toml:"run"` + Skip []string `toml:"skip"` + Default []string `toml:"default"` + patterns []*regexp.Regexp +} + +type mapping struct { + Version int `toml:"version"` + Jobs []string `toml:"jobs"` + Rules []rule `toml:"rules"` + Requires map[string][]string `toml:"requires"` +} + +func loadMapping(path string) (mapping, error) { + var result mapping + meta, err := toml.DecodeFile(path, &result) + if err != nil { + return result, errors.Wrap(err, "read TOML rules") + } + if meta.IsDefined("requires") && meta.Type("requires") != "Hash" { + return result, fmt.Errorf("requires must be a table") + } + return result, result.validate() +} + +func (m *mapping) validate() error { + if m.Version != 1 { + return fmt.Errorf("unsupported rules version: %d", m.Version) + } + if len(m.Jobs) == 0 { + return fmt.Errorf("jobs must list at least one target") + } + jobs := map[string]bool{} + for _, job := range m.Jobs { + if jobs[job] { + return fmt.Errorf("duplicate job name %s", job) + } + if job == "" || strings.ContainsAny(job, "\r\n#") { + return fmt.Errorf("bad job name %q", job) + } + jobs[job] = true + } + if len(m.Rules) == 0 { + return fmt.Errorf("rules must be a non-empty list") + } + names := map[string]bool{} + for index := range m.Rules { + r := &m.Rules[index] + if err := r.validate(index+1, jobs); err != nil { + return err + } + if names[r.Name] { + return fmt.Errorf("duplicate rule name %s", r.Name) + } + names[r.Name] = true + if r.When == "remaining" && index != len(m.Rules)-1 { + return fmt.Errorf("rule %d %s uses remaining, which only the last rule may use", index+1, r.Name) + } + } + last := m.Rules[len(m.Rules)-1] + if last.When != "remaining" { + return fmt.Errorf("the last rule must decide every target no earlier rule decided") + } + if !slices.Contains(last.Run, "*") && !slices.Contains(last.Skip, "*") && !slices.Contains(last.Default, "*") { + return fmt.Errorf("the last rule must run, skip, or take the default for every remaining target") + } + for job, required := range m.Requires { + if !jobs[job] { + return fmt.Errorf("unknown job %s in requires", job) + } + if len(required) == 0 { + return fmt.Errorf("%s requires a non-empty list", job) + } + seen := map[string]bool{} + for _, dependency := range required { + if !jobs[dependency] { + return fmt.Errorf("unknown job %s required by %s", dependency, job) + } + if dependency == job { + return fmt.Errorf("%s requires itself", job) + } + if seen[dependency] { + return fmt.Errorf("duplicate requirement for %s", job) + } + seen[dependency] = true + } + } + return m.checkCycles() +} + +func (r *rule) validate(index int, jobs map[string]bool) error { + where := fmt.Sprintf("rule %d %s", index, r.Name) + if r.Name == "" { + return fmt.Errorf("rule %d needs a name", index) + } + pathRule := r.When == "any-file-matches" || r.When == "every-file-matches" + switch r.When { + case "any-file-matches", "every-file-matches", "always", "label-exists", "no-file-changed", "remaining": + default: + return fmt.Errorf("%s has unknown when: %q", where, r.When) + } + if pathRule && len(r.Paths) == 0 { + return fmt.Errorf("%s needs paths", where) + } + if !pathRule && len(r.Paths) != 0 { + return fmt.Errorf("%s does not take paths", where) + } + if r.When == "label-exists" && r.Label == "" { + return fmt.Errorf("%s needs a label", where) + } + if r.When != "label-exists" && r.Label != "" { + return fmt.Errorf("%s does not take a label", where) + } + seen := map[string]bool{} + starred := 0 + for _, list := range [][]string{r.Run, r.Skip, r.Default} { + local := map[string]bool{} + for _, job := range list { + if job == "*" { + if len(list) != 1 { + return fmt.Errorf("%s mixes * with job names", where) + } + starred++ + continue + } + if !jobs[job] { + return fmt.Errorf("unknown job %s in %s", job, where) + } + if local[job] { + return fmt.Errorf("%s repeats a job", where) + } + if seen[job] { + return fmt.Errorf("%s gives one job two opinions", where) + } + local[job], seen[job] = true, true + } + } + if starred > 1 { + return fmt.Errorf("%s uses * more than once", where) + } + if starred != 0 && len(seen) != 0 { + return fmt.Errorf("%s mixes * with job names", where) + } + if starred == 0 && len(seen) == 0 { + return fmt.Errorf("%s must run, skip, or take the default", where) + } + r.patterns = make([]*regexp.Regexp, 0, len(r.Paths)) + for _, pattern := range r.Paths { + compiled, err := regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("invalid pattern %q in %s: %w", pattern, where, err) + } + r.patterns = append(r.patterns, compiled) + } + return nil +} + +func (m mapping) checkCycles() error { + visiting := map[string]bool{} + visited := map[string]bool{} + var walk func(string) error + walk = func(job string) error { + if visiting[job] { + return fmt.Errorf("requires cycle at %s", job) + } + if visited[job] { + return nil + } + visiting[job] = true + for _, dependency := range m.Requires[job] { + if err := walk(dependency); err != nil { + return err + } + } + delete(visiting, job) + visited[job] = true + return nil + } + for _, job := range m.Jobs { + if err := walk(job); err != nil { + return err + } + } + return nil +} diff --git a/ci/select/decision.go b/ci/select/decision.go new file mode 100644 index 0000000000000..60c541f63ec1f --- /dev/null +++ b/ci/select/decision.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "strings" +) + +type defaults struct { + jobs []string + actions map[string]string +} + +func readList(text string) []string { + lines := []string{} + for line := range strings.SplitSeq(text, "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + return lines +} + +func parseDefaults(text string) (defaults, error) { + result := defaults{jobs: []string{}, actions: map[string]string{}} + for index, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(line, "#") { + return result, fmt.Errorf("line %d mixes a target and a comment", index+1) + } + words := strings.Fields(line) + if len(words) < 2 { + return result, fmt.Errorf("line %d needs a target and a decision", index+1) + } + action := words[len(words)-1] + if action != "run" && action != "skip" { + return result, fmt.Errorf("line %d decision must be run or skip", index+1) + } + // Preserve spaces within check names; only the final word is the action. + job := strings.TrimSpace(line[:len(line)-len(action)]) + if _, exists := result.actions[job]; exists { + return result, fmt.Errorf("duplicate default for %s", job) + } + result.jobs = append(result.jobs, job) + result.actions[job] = action + } + if len(result.jobs) == 0 { + return result, fmt.Errorf("decision-defaults lists no targets") + } + return result, nil +} + +func parseDecision(text string) (map[string]bool, error) { + jobs := map[string]bool{} + if strings.TrimSpace(text) == "" { + return jobs, fmt.Errorf("empty decision") + } + for index, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(line, "#") { + return jobs, fmt.Errorf("line %d mixes a job name and a comment", index+1) + } + jobs[line] = true + } + return jobs, nil +} + +func formatDecision(jobs []string) string { + if len(jobs) == 0 { + return "# no job runs\n" + } + return strings.Join(jobs, "\n") + "\n" +} + +func (d defaults) decision() string { + jobs := []string{} + for _, job := range d.jobs { + if d.actions[job] == "run" { + jobs = append(jobs, job) + } + } + return formatDecision(jobs) +} + +func gateAction(job string, jobs map[string]bool, d defaults, enforce, forced bool) string { + if !enforce || d.actions[job] == "" || forced { + return "run" + } + if jobs == nil { + return d.actions[job] + } + if jobs[job] { + return "run" + } + return "skip" +} diff --git a/ci/select/decision_test.go b/ci/select/decision_test.go new file mode 100644 index 0000000000000..9336bfad5f83a --- /dev/null +++ b/ci/select/decision_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaults(t *testing.T) { + d, err := parseDefaults("# defaults\nunit run\ncheck (GOTAGS=\"\") skip\n") + require.NoError(t, err) + assert.Equal(t, "unit\n", d.decision()) + assert.Equal(t, "skip", d.actions["check (GOTAGS=\"\")"]) + for name, text := range map[string]string{ + "empty": "", "comments only": "# comment", "duplicate": "unit run\nunit skip", + "inline comment": "unit run # note", "no action": "unit", "bad action": "unit maybe", + } { + t.Run(name, func(t *testing.T) { + _, err := parseDefaults(text) + require.Error(t, err) + }) + } +} + +func TestDecision(t *testing.T) { + jobs, err := parseDecision("unit\n# note\nunit\n") + require.NoError(t, err) + assert.Equal(t, map[string]bool{"unit": true}, jobs) + jobs, err = parseDecision(formatDecision(nil)) + require.NoError(t, err) + assert.Empty(t, jobs) + for name, text := range map[string]string{"blank": " \n", "inline comment": "unit # note"} { + t.Run(name, func(t *testing.T) { + _, err := parseDecision(text) + require.Error(t, err) + }) + } +} + +func TestGateAction(t *testing.T) { + d := defaults{actions: map[string]string{"unit": "run", "e2e": "skip"}} + cases := map[string]struct { + job string + jobs map[string]bool + enforce, forced bool + want string + }{ + "shadow runs": {job: "unit", jobs: map[string]bool{}, want: "run"}, + "listed": {job: "unit", jobs: map[string]bool{"unit": true}, enforce: true, want: "run"}, + "absent": {job: "unit", jobs: map[string]bool{}, enforce: true, want: "skip"}, + "outside": {job: "unknown", jobs: map[string]bool{}, enforce: true, want: "run"}, + "missing decision": {job: "unit", enforce: true, want: "run"}, + "skip default": {job: "e2e", enforce: true, want: "skip"}, + "test override": {job: "e2e", jobs: map[string]bool{}, enforce: true, forced: true, want: "run"}, + } + for name, test := range cases { + t.Run(name, func(t *testing.T) { + assert.Equal(t, test.want, gateAction(test.job, test.jobs, d, test.enforce, test.forced)) + }) + } +} diff --git a/ci/select/gha-gate.sh b/ci/select/gha-gate.sh new file mode 100755 index 0000000000000..5e5e10c1500ca --- /dev/null +++ b/ci/select/gha-gate.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +export CI_SELECT_ROOT="$root" +if "$root/ci/select/bin/ci-select" gha-ci "$@"; then + exit 0 +fi +echo "CI decision gate failed; the job runs" >&2 +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf 'action=run\n' >>"$GITHUB_OUTPUT" +fi diff --git a/ci/select/gha.go b/ci/select/gha.go new file mode 100644 index 0000000000000..b69da14d8e718 --- /dev/null +++ b/ci/select/gha.go @@ -0,0 +1,219 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "slices" + "strconv" + "strings" + + "github.com/pkg/errors" +) + +const enforceLabel = "ci-dispatcher-enforce" + +func gitOutput(ctx context.Context, args ...string) (string, error) { + command := exec.CommandContext(ctx, "git", append([]string{"-C", rootDir()}, args...)...) + text, err := command.Output() + return strings.TrimSuffix(string(text), "\n"), err +} + +func changedFiles(ctx context.Context, base, head string) ([]string, bool) { + if base == "" || head == "" { + return []string{}, true + } + text, err := gitOutput(ctx, "diff", "--name-only", "-z", base+"..."+head, "--") + if err != nil { + return []string{}, true + } + if text == "" { + return []string{}, false + } + return strings.Split(strings.TrimSuffix(text, "\x00"), "\x00"), false +} + +func ghaInputs(ctx context.Context) (result, defaults, error) { + files, unknown := changedFiles(ctx, os.Getenv("CI_BASE"), os.Getenv("CI_HEAD")) + labels := readList(strings.ReplaceAll(os.Getenv("PR_LABELS"), ",", "\n")) + rules, defaultsPath := rulePaths() + return openDecision(rules, defaultsPath, files, labels, unknown, envMetadata()) +} + +func envMetadata() metadata { + return metadata{ + repo: envOr("CI_REPO", envOr("GITHUB_REPOSITORY", "stackrox/stackrox")), + pr: envOr("CI_PR", "0"), + commit: envOr("CI_COMMIT", "unknown"), + } +} + +func envOr(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} + +func ghaCI(ctx context.Context, args []string, output, diagnostic io.Writer) error { + if len(args) > 1 { + return fmt.Errorf("gha-ci takes at most one job name") + } + resolved, d, err := ghaInputs(ctx) + if err != nil { + return err + } + if path := os.Getenv("CI_DECISION_OUT"); path != "" { + if err := os.WriteFile(path, []byte(resolved.decision), 0o600); err != nil { + return errors.Wrap(err, "write decision file") + } + } + enforce := os.Getenv("CI_ENFORCE") == "true" + if len(args) == 0 || args[0] == "" { + return printPlan(resolved, enforce, output) + } + job := args[0] + planned := gateAction(job, resolved.jobs, d, true, false) + action := gateAction(job, resolved.jobs, d, enforce, false) + fmt.Fprint(diagnostic, resolved.log) + fmt.Fprintf(diagnostic, "%s: decision would %s; enforce=%t\n", job, planned, enforce) + return writeOutputs(map[string]string{"action": action}, output) +} + +func printPlan(resolved result, enforce bool, output io.Writer) error { + label := "absent" + if enforce { + label = "set" + } + body := fmt.Sprintf("ci-dispatcher-enforce is %s.\n\nDecision:\n%s\nLog:\n%s", label, resolved.decision, resolved.log) + if _, err := io.WriteString(output, body); err != nil { + return errors.Wrap(err, "print decision plan") + } + return appendFile(os.Getenv("GITHUB_STEP_SUMMARY"), "## CI decision\n\n```\n"+body+"```\n") +} + +func appendFile(path, text string) error { + if path == "" { + return nil + } + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return errors.Wrap(err, "open CI output") + } + _, writeErr := io.WriteString(file, text) + closeErr := file.Close() + if writeErr != nil { + return errors.Wrap(writeErr, "write CI output") + } + return errors.Wrap(closeErr, "close CI output") +} + +func writeOutputs(values map[string]string, output io.Writer) error { + keys := []string{} + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + var text strings.Builder + for _, key := range keys { + fmt.Fprintf(&text, "%s=%s\n", key, values[key]) + } + if _, err := io.WriteString(output, text.String()); err != nil { + return errors.Wrap(err, "print CI outputs") + } + return appendFile(os.Getenv("GITHUB_OUTPUT"), text.String()) +} + +type dispatchJob struct { + name string + requireLabel bool + requireKonflux bool +} + +var dispatchJobs = []dispatchJob{ + {name: "e2e-byodb-tests", requireLabel: true}, + {name: "e2e-db-backup-restore-tests"}, + {name: "e2e-gke-upgrade-tests"}, + {name: "e2e-nongroovy-tests"}, + {name: "e2e-qa-tests-gke", requireLabel: true}, + {name: "e2e-qa-tests-gke-konflux", requireKonflux: true}, + {name: "e2e-qa-tests-ocp-fips-konflux", requireKonflux: true}, +} + +type dispatchInput struct { + pullRequest, fork, draft, uiOnly, enforce bool + labels []string +} + +func legacyDispatch(job dispatchJob, input dispatchInput) bool { + if input.fork { + return false + } + if !input.pullRequest || slices.Contains(input.labels, job.name) { + return true + } + if job.requireKonflux && !slices.Contains(input.labels, "konflux-build") { + return false + } + return !input.uiOnly && !job.requireLabel && !input.draft +} + +func dispatchPlan(input dispatchInput, resolved *result, d defaults) map[string]string { + jobs := map[string]bool{} + anyRuns := false + for _, job := range dispatchJobs { + runs := legacyDispatch(job, input) + if input.enforce && resolved != nil && d.actions[job.name] != "" { + runs = !input.fork && resolved.jobs[job.name] + } + jobs[job.name] = runs + anyRuns = anyRuns || runs + } + waitGHA := !input.fork && (!input.pullRequest || !input.draft || anyRuns) + if input.enforce && resolved != nil && d.actions["wait-for-images"] != "" { + waitGHA = !input.fork && resolved.jobs["wait-for-images"] + } + // The Konflux label is required for its PR builds even when selection is enforced. + waitKonflux := !input.pullRequest || (slices.Contains(input.labels, "konflux-build") && anyRuns) + encoded, _ := json.Marshal(jobs) + return map[string]string{ + "jobs": string(encoded), + "wait-for-gha-images": strconv.FormatBool(waitGHA), + "wait-for-konflux-images": strconv.FormatBool(waitKonflux), + } +} + +func ghaDispatchCI(ctx context.Context, output, diagnostic io.Writer) error { + input := dispatchInput{ + pullRequest: os.Getenv("GITHUB_EVENT_NAME") == "pull_request", + fork: os.Getenv("CI_IS_FORK") == "true", + draft: os.Getenv("CI_IS_DRAFT") == "true", + uiOnly: os.Getenv("CI_UI_ONLY") == "true", + } + if input.pullRequest { + meta := envMetadata() + labels, err := newGitHubClient().labels(ctx, meta.repo, meta.pr) + if err != nil { + return fmt.Errorf("read dispatch labels: %w", err) + } + input.labels = labels + input.enforce = slices.Contains(labels, enforceLabel) + } + var resolved *result + var d defaults + if input.pullRequest && input.enforce { + files, unknown := changedFiles(ctx, os.Getenv("CI_BASE"), os.Getenv("CI_HEAD")) + rules, defaultsPath := rulePaths() + plan, owned, err := openDecision(rules, defaultsPath, files, input.labels, unknown, envMetadata()) + if err != nil { + fmt.Fprintf(diagnostic, "CI decision unreadable (%v); using existing dispatch rules\n", err) + } else { + resolved, d = &plan, owned + fmt.Fprint(diagnostic, plan.log) + } + } + return writeOutputs(dispatchPlan(input, resolved, d), output) +} diff --git a/ci/select/gha_test.go b/ci/select/gha_test.go new file mode 100644 index 0000000000000..9622e36a7a74b --- /dev/null +++ b/ci/select/gha_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGHAOutputs(t *testing.T) { + t.Setenv("CI_SELECT_ROOT", "../..") + t.Setenv("CI_BASE", "missing-ref") + t.Setenv("CI_HEAD", "HEAD") + t.Setenv("CI_ENFORCE", "true") + path := filepath.Join(t.TempDir(), "output") + t.Setenv("GITHUB_OUTPUT", path) + var output bytes.Buffer + require.NoError(t, ghaCI(t.Context(), []string{"gke-qa-e2e-tests"}, &output, &bytes.Buffer{})) + text, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "action=skip\n", string(text)) + assert.Equal(t, string(text), output.String()) +} + +func TestDispatchPlan(t *testing.T) { + d := defaults{actions: map[string]string{"e2e-nongroovy-tests": "run", "wait-for-images": "run"}} + for name, test := range map[string]struct { + input dispatchInput + resolved *result + nongroovy, byodb, waitGHA, waitKonflux bool + }{ + "push": {input: dispatchInput{}, nongroovy: true, byodb: true, waitGHA: true, waitKonflux: true}, + "ready PR": {input: dispatchInput{pullRequest: true}, nongroovy: true, waitGHA: true}, + "draft": {input: dispatchInput{pullRequest: true, draft: true}}, + "UI": {input: dispatchInput{pullRequest: true, uiOnly: true}, waitGHA: true}, + "explicit label": {input: dispatchInput{pullRequest: true, draft: true, labels: []string{"e2e-byodb-tests"}}, byodb: true, waitGHA: true}, + "Konflux": {input: dispatchInput{pullRequest: true, labels: []string{"konflux-build"}}, nongroovy: true, waitGHA: true, waitKonflux: true}, + "enforce skip": {input: dispatchInput{pullRequest: true, enforce: true}, resolved: &result{jobs: map[string]bool{}}}, + "shadow": {input: dispatchInput{pullRequest: true}, resolved: &result{jobs: map[string]bool{}}, nongroovy: true, waitGHA: true}, + "fork": {input: dispatchInput{pullRequest: true, fork: true, enforce: true}, resolved: &result{jobs: map[string]bool{"e2e-nongroovy-tests": true, "wait-for-images": true}}}, + } { + t.Run(name, func(t *testing.T) { + plan := dispatchPlan(test.input, test.resolved, d) + jobs := map[string]bool{} + require.NoError(t, json.Unmarshal([]byte(plan["jobs"]), &jobs)) + assert.Equal(t, test.nongroovy, jobs["e2e-nongroovy-tests"]) + assert.Equal(t, test.byodb, jobs["e2e-byodb-tests"]) + assert.Equal(t, fmt.Sprint(test.waitGHA), plan["wait-for-gha-images"]) + assert.Equal(t, fmt.Sprint(test.waitKonflux), plan["wait-for-konflux-images"]) + }) + } +} + +func TestChangedFiles(t *testing.T) { + root := t.TempDir() + t.Setenv("CI_SELECT_ROOT", root) + git := func(args ...string) string { + t.Helper() + command := exec.CommandContext(t.Context(), "git", append([]string{ + "-C", root, "-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", + }, args...)...) + text, err := command.CombinedOutput() + require.NoError(t, err, string(text)) + return string(bytes.TrimSpace(text)) + } + git("init") + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "base") + base := git("rev-parse", "HEAD") + files, unknown := changedFiles(t.Context(), base, "HEAD") + assert.False(t, unknown) + assert.Empty(t, files) + writeTestFile(t, filepath.Join(root, "with space.go"), "package example\n", 0o600) + git("add", "with space.go") + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "change") + files, unknown = changedFiles(t.Context(), base, "HEAD") + assert.False(t, unknown) + assert.Equal(t, []string{"with space.go"}, files) + _, unknown = changedFiles(t.Context(), "missing", "HEAD") + assert.True(t, unknown) +} + +func TestGHADispatchCI(t *testing.T) { + t.Setenv("CI_SELECT_ROOT", "../..") + t.Setenv("GITHUB_EVENT_NAME", "pull_request") + t.Setenv("CI_PR", "23166") + t.Setenv("CI_REPO", "stackrox/stackrox") + t.Setenv("CI_BASE", "missing") + t.Setenv("CI_IS_FORK", "true") + t.Setenv("GITHUB_OUTPUT", "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"name":"ci-dispatcher-enforce"}]`) + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) + var output bytes.Buffer + require.NoError(t, ghaDispatchCI(t.Context(), &output, &bytes.Buffer{})) + assert.Contains(t, output.String(), `"e2e-nongroovy-tests":false`) + assert.Contains(t, output.String(), "wait-for-gha-images=false") +} diff --git a/ci/select/github.go b/ci/select/github.go new file mode 100644 index 0000000000000..28e41090d9a39 --- /dev/null +++ b/ci/select/github.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" +) + +type githubClient struct { + baseURL string + token string + client *http.Client +} + +func newGitHubClient() githubClient { + base := os.Getenv("GITHUB_API_URL") + if base == "" { + base = "https://api.github.com" + } + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + token = os.Getenv("GH_TOKEN") + } + return githubClient{baseURL: strings.TrimRight(base, "/"), token: token, client: &http.Client{Timeout: 30 * time.Second}} +} + +func (g githubClient) get(ctx context.Context, path string, destination any) error { + for attempt := range 3 { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, g.baseURL+path, nil) + if err != nil { + return errors.Wrap(err, "create GitHub request") + } + request.Header.Set("Accept", "application/vnd.github+json") + if g.token != "" { + request.Header.Set("Authorization", "Bearer "+g.token) + } + response, err := g.client.Do(request) + if err == nil { + if response.StatusCode == http.StatusOK { + decodeErr := json.NewDecoder(io.LimitReader(response.Body, 32<<20)).Decode(destination) + closeErr := response.Body.Close() + if decodeErr != nil { + return errors.Wrap(decodeErr, "decode GitHub response") + } + return errors.Wrap(closeErr, "close GitHub response") + } + if closeErr := response.Body.Close(); closeErr != nil { + return errors.Wrap(closeErr, "close GitHub error response") + } + err = fmt.Errorf("GitHub request returned HTTP %d", response.StatusCode) + if response.StatusCode < 500 { + return err + } + } + if attempt == 2 { + return err + } + select { + case <-ctx.Done(): + return errors.Wrap(ctx.Err(), "GitHub request canceled") + case <-time.After(time.Duration(attempt+1) * time.Second): + } + } + return fmt.Errorf("GitHub request failed") +} + +func repositoryPath(repo, pr string) (string, error) { + parts := strings.Split(repo, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("invalid repository %q", repo) + } + number, err := strconv.Atoi(pr) + if err != nil || number < 1 { + return "", fmt.Errorf("invalid pull request number %q", pr) + } + return "/repos/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]) + "/issues/" + strconv.Itoa(number), nil +} + +func (g githubClient) labels(ctx context.Context, repo, pr string) ([]string, error) { + path, err := repositoryPath(repo, pr) + if err != nil { + return nil, err + } + labels := []string{} + for page := 1; ; page++ { + items := []struct { + Name string `json:"name"` + }{} + if err := g.get(ctx, fmt.Sprintf("%s/labels?per_page=100&page=%d", path, page), &items); err != nil { + return nil, err + } + if items == nil { + return nil, fmt.Errorf("GitHub labels response must be an array") + } + for _, item := range items { + labels = append(labels, item.Name) + } + if len(items) < 100 { + return labels, nil + } + } +} + +func (g githubClient) comments(ctx context.Context, repo, pr string) ([]comment, error) { + path, err := repositoryPath(repo, pr) + if err != nil { + return nil, err + } + comments := []comment{} + for page := 1; ; page++ { + items := []comment{} + if err := g.get(ctx, fmt.Sprintf("%s/comments?per_page=100&page=%d", path, page), &items); err != nil { + return nil, err + } + if items == nil { + return nil, fmt.Errorf("GitHub comments response must be an array") + } + comments = append(comments, items...) + if len(items) < 100 { + return comments, nil + } + } +} diff --git a/ci/select/main.go b/ci/select/main.go new file mode 100644 index 0000000000000..52a64b46fd55e --- /dev/null +++ b/ci/select/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" +) + +//nolint:forbidigo // Commands accept injectable streams; the process entry point supplies standard I/O. +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + code, err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr) + if err != nil { + fmt.Fprintln(os.Stderr, "ci-select:", err) + } + os.Exit(code) +} diff --git a/ci/select/prow-gate.sh b/ci/select/prow-gate.sh new file mode 100755 index 0000000000000..7d5e9541803cc --- /dev/null +++ b/ci/select/prow-gate.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2329 # dispatch.sh calls this function after sourcing the file. +ci_decision_allows() { + local status=0 + get_pr_details | CI_SELECT_ROOT="$ROOT" "$ROOT/ci/select/bin/ci-select" prow-ci "${1:?job name}" || status=$? + # Only an explicit skip (3) stops the job; setup and API failures let it run. + [[ "$status" -ne 3 ]] +} diff --git a/ci/select/prow.go b/ci/select/prow.go new file mode 100644 index 0000000000000..370fb848af0e2 --- /dev/null +++ b/ci/select/prow.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" +) + +var testLine = regexp.MustCompile(`^/test\b(.*)$`) + +type comment struct { + CreatedAt string `json:"created_at"` + Body string `json:"body"` +} + +func commentRequests(job, body string) bool { + for line := range strings.SplitSeq(body, "\n") { + match := testLine.FindStringSubmatch(strings.TrimSpace(line)) + if match == nil { + continue + } + names := strings.Fields(match[1]) + if slices.Contains(names, job) || slices.Contains(names, "all") { + return true + } + } + return false +} + +func requestsSince(comments []comment, job, commitTime string) bool { + commit, err := time.Parse(time.RFC3339Nano, commitTime) + if err != nil { + return false + } + for _, comment := range comments { + if !commentRequests(job, comment.Body) { + continue + } + created, err := time.Parse(time.RFC3339Nano, comment.CreatedAt) + if err == nil && !created.Before(commit) { + return true + } + } + return false +} + +func readComments(path string) ([]comment, error) { + text, err := os.ReadFile(path) + if err != nil { + return nil, errors.Wrap(err, "read comments file") + } + comments := []comment{} + if err := json.Unmarshal(text, &comments); err != nil { + return nil, errors.Wrap(err, "decode comments file") + } + if comments == nil { + return nil, fmt.Errorf("comments file must be a list") + } + return comments, nil +} + +type pullRequest struct { + Number int `json:"number"` + Body string `json:"body"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Base struct { + Repo struct { + FullName string `json:"full_name"` + } `json:"repo"` + } `json:"base"` +} + +func prowCI(ctx context.Context, job string, input io.Reader, diagnostic io.Writer) (string, error) { + var pr pullRequest + if err := json.NewDecoder(input).Decode(&pr); err != nil { + return "run", fmt.Errorf("read PR details: %w", err) + } + meta := metadata{repo: pr.Base.Repo.FullName, pr: strconv.Itoa(pr.Number)} + if _, err := repositoryPath(meta.repo, meta.pr); err != nil { + return "run", err + } + labels := make([]string, 0, len(pr.Labels)) + for _, label := range pr.Labels { + labels = append(labels, label.Name) + } + enforced := slices.Contains(labels, enforceLabel) + if meta.repo == "openshift/release" { + // Rehearsal PRs carry the requested label in their description. + enforced = regexp.MustCompile(`/label:\s*ci-dispatcher-enforce`).MatchString(pr.Body) + } + files, unknown := changedFiles(ctx, os.Getenv("PULL_BASE_SHA"), "HEAD") + head := envOr("PULL_PULL_SHA", "HEAD") + commit, err := gitOutput(ctx, "rev-parse", "--short=7", head) + if err != nil { + commit = "unknown" + } + meta.commit = commit + // Prow's synthetic merge can be newer than a /test comment on the PR head. + commitTime, _ := gitOutput(ctx, "show", "-s", "--format=%cI", head) + comments, err := newGitHubClient().comments(ctx, meta.repo, meta.pr) + if err != nil { + return "run", fmt.Errorf("read /test comments; job will run: %w", err) + } + rules, defaultsPath := rulePaths() + resolved, d, err := openDecision(rules, defaultsPath, files, labels, unknown, meta) + if err != nil { + return "run", err + } + fmt.Fprint(diagnostic, resolved.log) + forced := requestsSince(comments, job, commitTime) + if forced { + fmt.Fprintf(diagnostic, "a /test comment runs %s\n", job) + } + action := gateAction(job, resolved.jobs, d, enforced, forced) + fmt.Fprintf(diagnostic, "%s: %s (enforce=%t)\n", job, action, enforced) + return action, nil +} diff --git a/ci/select/prow_test.go b/ci/select/prow_test.go new file mode 100644 index 0000000000000..6b944130d806e --- /dev/null +++ b/ci/select/prow_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommentRequests(t *testing.T) { + for body, want := range map[string]bool{ + "/test unit": true, " /test all ": true, "hi\n/test unit e2e": true, + "/test unity": false, "/test": false, "quoted /test unit": false, + "/testing unit": false, "/test,unit": false, + } { + t.Run(body, func(t *testing.T) { assert.Equal(t, want, commentRequests("unit", body)) }) + } +} + +func TestRequestsSince(t *testing.T) { + for name, test := range map[string]struct { + created, commit string + want bool + }{ + "equal": {created: "2026-09-30T12:00:00Z", commit: "2026-09-30T12:00:00Z", want: true}, + "older": {created: "2026-09-30T11:59:59Z", commit: "2026-09-30T12:00:00Z"}, + "offset": {created: "2026-09-30T14:00:00+02:00", commit: "2026-09-30T12:00:00Z", want: true}, + "invalid comment": {created: "bad", commit: "2026-09-30T12:00:00Z"}, + "invalid commit": {created: "2026-09-30T12:00:00Z", commit: "bad"}, + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, test.want, requestsSince([]comment{{CreatedAt: test.created, Body: "/test unit"}}, "unit", test.commit)) + }) + } +} + +func TestProwCI(t *testing.T) { + t.Setenv("CI_SELECT_ROOT", "../..") + t.Setenv("PULL_BASE_SHA", "missing-ref") + t.Setenv("PULL_PULL_SHA", "HEAD") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "[]") + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) + input := `{"number":23166,"labels":[{"name":"ci-dispatcher-enforce"}],"base":{"repo":{"full_name":"stackrox/stackrox"}}}` + code, err := run(t.Context(), []string{"prow-ci", "gke-qa-e2e-tests"}, strings.NewReader(input), &bytes.Buffer{}, &bytes.Buffer{}) + require.NoError(t, err) + assert.Equal(t, 3, code) + code, err = run(t.Context(), []string{"prow-ci", "unknown-job"}, strings.NewReader(input), &bytes.Buffer{}, &bytes.Buffer{}) + require.NoError(t, err) + assert.Zero(t, code) + code, err = run(t.Context(), []string{"prow-ci", "unit"}, strings.NewReader("invalid"), &bytes.Buffer{}, &bytes.Buffer{}) + require.Error(t, err) + assert.NotEqual(t, 3, code) +} + +func TestProwCIComments(t *testing.T) { + t.Setenv("CI_SELECT_ROOT", "../..") + t.Setenv("PULL_BASE_SHA", "missing-ref") + t.Setenv("PULL_PULL_SHA", "HEAD") + input := `{"number":23166,"labels":[{"name":"ci-dispatcher-enforce"}],"base":{"repo":{"full_name":"stackrox/stackrox"}}}` + for name, test := range map[string]struct { + status int + body string + wantError bool + }{ + "test forces run": {status: http.StatusOK, body: `[{"created_at":"2099-01-01T00:00:00Z","body":"/test gke-qa-e2e-tests"}]`}, + "API unavailable": {status: http.StatusForbidden, body: `{}`, wantError: true}, + "malformed response": {status: http.StatusOK, body: `{}`, wantError: true}, + "null response": {status: http.StatusOK, body: `null`, wantError: true}, + } { + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(test.status) + fmt.Fprint(w, test.body) + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) + code, err := run(t.Context(), []string{"prow-ci", "gke-qa-e2e-tests"}, strings.NewReader(input), + &bytes.Buffer{}, &bytes.Buffer{}) + assert.NotEqual(t, 3, code) + if test.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Zero(t, code) + } + }) + } +} + +func TestCommentsPagination(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + comments := []comment{} + if r.URL.Query().Get("page") == "1" { + comments = make([]comment, 100) + } else { + comments = append(comments, comment{CreatedAt: "2026-09-30T12:00:00Z", Body: "/test unit"}) + } + assert.NoError(t, json.NewEncoder(w).Encode(comments)) + })) + defer server.Close() + g := githubClient{baseURL: server.URL, client: server.Client()} + comments, err := g.comments(t.Context(), "stackrox/stackrox", "23166") + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.True(t, requestsSince(comments, "unit", "2026-09-30T11:00:00Z")) +} + +func TestProwWrapper(t *testing.T) { + for name, status := range map[string]int{"run": 0, "skip": 3, "failure": 1} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root+"/ci/select/bin/ci-select", fmt.Sprintf("#!/bin/sh\nexit %d\n", status), 0o700) + command := exec.CommandContext(t.Context(), "bash", "-c", + `source ./prow-gate.sh; get_pr_details() { echo '{}'; }; ci_decision_allows unit`) + command.Env = append(command.Environ(), "ROOT="+root) + err := command.Run() + assert.Equal(t, status == 3, err != nil) + }) + } +} diff --git a/ci/select/resolver.go b/ci/select/resolver.go new file mode 100644 index 0000000000000..b17c3cfe2f417 --- /dev/null +++ b/ci/select/resolver.go @@ -0,0 +1,335 @@ +package main + +import ( + "fmt" + "os" + "slices" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" +) + +type metadata struct { + repo string + pr string + commit string +} + +type vote struct { + run []string + skip []string + defaultRules []string +} + +type result struct { + decision string + log string + jobs map[string]bool +} + +func resolve(files, labels []string, m mapping, d defaults, meta metadata) (result, error) { + for _, job := range m.Jobs { + if d.actions[job] == "" { + return result{}, fmt.Errorf("decision-defaults is missing %s", job) + } + } + for _, job := range d.jobs { + if !slices.Contains(m.Jobs, job) { + return result{}, fmt.Errorf("decision-defaults has unknown target %s", job) + } + } + files = dedupe(files) + votes := map[string]*vote{} + for _, job := range m.Jobs { + votes[job] = &vote{} + } + for _, r := range m.Rules { + if r.When != "remaining" && !r.matches(files, labels) { + continue + } + for _, job := range m.Jobs { + v := votes[job] + if r.When == "remaining" && len(v.run)+len(v.skip)+len(v.defaultRules) != 0 { + continue + } + if namesJob(r.Run, job) { + v.run = append(v.run, r.Name) + } + if namesJob(r.Skip, job) { + v.skip = append(v.skip, r.Name) + } + if namesJob(r.Default, job) { + v.defaultRules = append(v.defaultRules, r.Name) + } + } + } + clashes := map[string]bool{} + fromDefault := map[string]bool{} + original := map[string]bool{} + for _, job := range m.Jobs { + v := votes[job] + switch { + case len(v.run) > 0: + original[job] = true + clashes[job] = len(v.skip) > 0 + case len(v.skip) > 0: + case len(v.defaultRules) > 0: + original[job] = d.actions[job] == "run" + fromDefault[job] = true + default: + return result{}, fmt.Errorf("%s has no decision", job) + } + } + running := map[string]bool{} + chosen := []string{} + for _, job := range m.Jobs { + if original[job] { + running[job] = true + chosen = append(chosen, job) + } + } + type addition struct{ job, because string } + added := []addition{} + for changed := true; changed; { + changed = false + for _, job := range m.Jobs { + if !running[job] { + continue + } + for _, needed := range m.Requires[job] { + if !running[needed] { + running[needed] = true + chosen = append(chosen, needed) + added = append(added, addition{job: needed, because: job}) + changed = true + } + } + } + } + events := []string{} + for _, r := range m.Rules { + if r.When == "remaining" { + continue + } + matched := false + actions := []string{} + for _, job := range m.Jobs { + v := votes[job] + run := slices.Contains(v.run, r.Name) + skip := slices.Contains(v.skip, r.Name) + matched = matched || run || skip || slices.Contains(v.defaultRules, r.Name) + if clashes[job] { + continue + } + if run { + actions = append(actions, fmt.Sprintf("rule %q runs target %q", r.Name, job)) + } + if skip { + actions = append(actions, fmt.Sprintf("rule %q skips target %q", r.Name, job)) + } + } + if matched { + if event := r.matchEvent(files); event != "" { + events = append(events, event) + } + } + events = append(events, actions...) + } + for _, job := range m.Jobs { + if clashes[job] { + v := votes[job] + events = append(events, fmt.Sprintf("clash on target %q; %s, %s; run wins", + job, says(v.run, "run"), says(v.skip, "skip"))) + } + } + for _, job := range m.Jobs { + if fromDefault[job] && (original[job] || !running[job]) { + events = append(events, fmt.Sprintf("rule %q takes default %q for target %q", + votes[job].defaultRules[0], d.actions[job], job)) + } + } + for _, entry := range added { + events = append(events, fmt.Sprintf("added target %q because target %q requires it", entry.job, entry.because)) + } + return result{decision: formatDecision(chosen), log: formatLog(meta, events), jobs: running}, nil +} + +func openDecision(rulesPath, defaultsPath string, files, labels []string, unknown bool, meta metadata) (result, defaults, error) { + text, err := os.ReadFile(defaultsPath) + if err != nil { + return result{}, defaults{}, errors.Wrap(err, "read decision-defaults") + } + d, err := parseDefaults(string(text)) + if err != nil { + return result{}, d, err + } + m, err := loadMapping(rulesPath) + if err == nil && unknown { + err = fmt.Errorf("changed files are unknown") + } + if err == nil { + var resolved result + resolved, err = resolve(files, labels, m, d, meta) + if err == nil { + return resolved, d, nil + } + } + decision := d.decision() + jobs, parseErr := parseDecision(decision) + if parseErr != nil { + return result{}, d, parseErr + } + return result{ + decision: decision, + jobs: jobs, + log: fmt.Sprintf("resolver failed: %v\nusing decision-defaults\n", err), + }, d, nil +} + +func namesJob(names []string, job string) bool { + return slices.Contains(names, "*") || slices.Contains(names, job) +} + +func (r rule) matches(files, labels []string) bool { + switch r.When { + case "always": + return true + case "no-file-changed": + return len(files) == 0 + case "label-exists": + return slices.Contains(labels, r.Label) + case "any-file-matches": + return slices.ContainsFunc(files, r.matchesPath) + case "every-file-matches": + return len(files) > 0 && !slices.ContainsFunc(files, func(path string) bool { return !r.matchesPath(path) }) + default: + return false + } +} + +func (r rule) matchesPath(path string) bool { + for _, pattern := range r.patterns { + if pattern.MatchString(path) { + return true + } + } + return false +} + +func (r rule) matchEvent(files []string) string { + switch r.When { + case "label-exists": + return fmt.Sprintf("rule %q matches because label %q is set", r.Name, r.Label) + case "no-file-changed": + return fmt.Sprintf("rule %q matches because the diff has no files", r.Name) + case "any-file-matches", "every-file-matches": + default: + return "" + } + groups := make([][]string, len(r.patterns)) + matched := 0 + for _, path := range files { + for index, pattern := range r.patterns { + if pattern.MatchString(path) { + groups[index] = append(groups[index], path) + matched++ + break + } + } + } + if matched == 0 { + return "" + } + header := fmt.Sprintf("rule %q matches", r.Name) + if r.When == "every-file-matches" { + header += " every changed file" + } else if matched > 1 && matched == len(files) { + header += fmt.Sprintf(" all %d changed files", matched) + } + lines := []string{header} + for index, paths := range groups { + if len(paths) == 0 { + continue + } + kind, label := patternKind(r.Paths[index]) + switch { + case kind == "exact": + lines = append(lines, " "+label) + case kind == "prefix" && matched != 1: + lines = append(lines, fmt.Sprintf(" %s (%d)", label, len(paths))) + default: + for _, path := range paths { + lines = append(lines, " "+path) + } + } + } + return strings.Join(lines, "\n") +} + +func patternKind(source string) (string, string) { + body := strings.TrimPrefix(source, "^") + body = strings.TrimSuffix(body, "$") + var literal strings.Builder + for index := 0; index < len(body); index++ { + char := body[index] + if char == '\\' { + index++ + if index == len(body) { + return "other", source + } + literal.WriteByte(body[index]) + continue + } + if strings.ContainsRune(".+*?[](){}|", rune(char)) { + return "other", source + } + literal.WriteByte(char) + } + if strings.HasPrefix(source, "^") { + if strings.HasSuffix(source, "$") { + return "exact", literal.String() + } + return "prefix", literal.String() + } + return "other", literal.String() +} + +func says(names []string, action string) string { + parts := make([]string, 0, len(names)) + for _, name := range names { + parts = append(parts, "rule "+strconv.Quote(name)) + } + verb := "says" + if len(names) > 1 { + verb = "say" + } + return strings.Join(parts, " and ") + " " + verb + " " + action +} + +func formatLog(meta metadata, events []string) string { + var log strings.Builder + commit := meta.commit + if len(commit) > 7 { + commit = commit[:7] + } + fmt.Fprintf(&log, "# %s PR %s %s\n# %s\n", meta.repo, meta.pr, commit, time.Now().Format(time.RFC3339)) + width := max(3, len(strconv.Itoa(len(events)))) + for index, event := range events { + fmt.Fprintf(&log, "%0*d. %s\n", width, index+1, event) + } + return log.String() +} + +func dedupe(values []string) []string { + seen := map[string]bool{} + unique := make([]string, 0, len(values)) + for _, value := range values { + if !seen[value] { + seen[value] = true + unique = append(unique, value) + } + } + return unique +} diff --git a/ci/select/resolver_test.go b/ci/select/resolver_test.go new file mode 100644 index 0000000000000..81635ae7beda1 --- /dev/null +++ b/ci/select/resolver_test.go @@ -0,0 +1,317 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type resolverCase struct { + Name string `json:"name"` + Input struct { + Files []string `json:"files"` + Labels []string `json:"labels"` + Rules string `json:"rules"` + Defaults string `json:"defaults"` + } `json:"input"` + Expect struct { + Decision string `json:"decision"` + Log string `json:"log"` + Error string `json:"error"` + } `json:"expect"` +} + +func fixturePath(t *testing.T, value string) string { + t.Helper() + if strings.Contains(value, "\n") { + path := filepath.Join(t.TempDir(), "config") + require.NoError(t, os.WriteFile(path, []byte(value), 0o600)) + return path + } + return strings.TrimPrefix(value, "ci/select/") +} + +func TestResolverCases(t *testing.T) { + text, err := os.ReadFile("testdata/edge_cases.json") + require.NoError(t, err) + cases := []resolverCase{} + require.NoError(t, json.Unmarshal(text, &cases)) + require.Len(t, cases, 18) + byName := map[string]resolverCase{} + for _, test := range cases { + require.NotContains(t, byName, test.Name, "fixture names must be unique") + byName[test.Name] = test + } + for name, test := range byName { + t.Run(name, func(t *testing.T) { + rules := fixturePath(t, test.Input.Rules) + if test.Expect.Error != "" { + assertRejectedRule(t, rules, test.Expect.Error) + return + } + resolved, _, err := openDecision(rules, fixturePath(t, test.Input.Defaults), + test.Input.Files, test.Input.Labels, test.Input.Files == nil, + metadata{repo: "stackrox/stackrox", pr: "23035", commit: "a1b2c3d"}) + require.NoError(t, err) + assert.Equal(t, test.Expect.Decision, resolved.decision) + jobs, err := parseDecision(test.Expect.Decision) + require.NoError(t, err) + assert.Equal(t, jobs, resolved.jobs) + lines := strings.Split(resolved.log, "\n") + if strings.HasPrefix(lines[0], "# ") && len(lines) > 1 { + lines[1] = "# " + } + assert.Equal(t, test.Expect.Log, strings.Join(lines, "\n")) + }) + } +} + +func TestRejectedRules(t *testing.T) { + text, err := os.ReadFile("testdata/rejected_rules.json") + require.NoError(t, err) + cases := []resolverCase{} + require.NoError(t, json.Unmarshal(text, &cases)) + require.Len(t, cases, 33) + byName := map[string]resolverCase{} + for _, test := range cases { + require.NotContains(t, byName, test.Name, "fixture names must be unique") + byName[test.Name] = test + } + for name, test := range byName { + t.Run(name, func(t *testing.T) { + assertRejectedRule(t, fixturePath(t, test.Input.Rules), test.Expect.Error) + }) + } +} + +func assertRejectedRule(t *testing.T, path, expected string) { + t.Helper() + _, err := loadMapping(path) + require.Error(t, err) + // TOML's typed decoder reports the offending key for type errors. + typeErrors := map[string]string{ + "jobs-not-a-list.toml": "jobs", + "rule-not-a-table.toml": "rules", + "label-not-a-string.toml": "rules.label", + "run-not-a-list.toml": "rules.run", + "paths-not-strings.toml": "rules.paths", + } + if key, found := typeErrors[filepath.Base(path)]; found { + assert.Contains(t, err.Error(), "read TOML rules") + assert.Contains(t, err.Error(), `last key "`+key+`"`) + return + } + expected = strings.ReplaceAll(expected, "'", `"`) + expected = strings.TrimSuffix(expected, " in run") + expected = strings.ReplaceAll(expected, " odd run mixes", " odd mixes") + assert.Contains(t, err.Error(), expected) +} + +func TestCurrentRules(t *testing.T) { + m, err := loadMapping("../test-domains.toml") + require.NoError(t, err) + text, err := os.ReadFile("../decision-defaults") + require.NoError(t, err) + d, err := parseDefaults(string(text)) + require.NoError(t, err) + cases := map[string]struct { + files, labels, runs, skips []string + complete bool + }{ + "docs": {files: []string{"README.md"}, runs: []string{"style-check", "wait-for-images", "should-dispatch"}, complete: true}, + "shared product": {files: []string{"sensor/common/foo.go", "central/policy/service.go"}, runs: []string{"go", "go-postgres", "sensor-integration-tests"}}, + "module": {files: []string{"go.mod"}, runs: m.Jobs, complete: true}, + "run all overrides docs": {files: []string{"README.md"}, labels: []string{"ci-run-all-tests"}, runs: m.Jobs, complete: true}, + "CI": {files: []string{"ci/select/gha.go"}, skips: []string{"e2e-byodb-tests", "go-postgres"}}, + } + for name, test := range cases { + t.Run(name, func(t *testing.T) { + resolved, err := resolve(test.files, test.labels, m, d, metadata{}) + require.NoError(t, err) + if test.complete { + expected := map[string]bool{} + for _, job := range test.runs { + expected[job] = true + } + assert.Equal(t, expected, resolved.jobs) + } + for _, job := range test.runs { + assert.True(t, resolved.jobs[job], job) + } + for _, job := range test.skips { + assert.False(t, resolved.jobs[job], job) + } + }) + } +} + +func TestMissingRulesUseDefaults(t *testing.T) { + resolved, _, err := openDecision("testdata/missing.toml", "testdata/defaults", nil, nil, false, metadata{}) + require.NoError(t, err) + assert.True(t, resolved.jobs["go"]) + assert.Contains(t, resolved.log, "using decision-defaults") +} + +func TestResolverDesignExample(t *testing.T) { + resolved, _, err := openDecision("testdata/rules.toml", "testdata/defaults", + []string{"README.md"}, nil, false, + metadata{repo: "stackrox/stackrox", pr: "23035", commit: "a1b2c3d12345"}) + require.NoError(t, err) + assert.Equal(t, "style-check\nwait-for-images\nshould-dispatch\n", resolved.decision) + assert.Equal(t, map[string]bool{"style-check": true, "wait-for-images": true, "should-dispatch": true}, resolved.jobs) + lines := strings.Split(resolved.log, "\n") + assert.Equal(t, "# stackrox/stackrox PR 23035 a1b2c3d", lines[0]) + _, err = time.Parse(time.RFC3339, strings.TrimPrefix(lines[1], "# ")) + require.NoError(t, err) + for _, expected := range []string{ + "001. rule \"docs-only\" matches every changed file\n README.md\n", + `002. rule "docs-only" skips target "go"`, + `009. rule "docs-only" skips target "e2e-nongroovy-tests"`, + `017. clash on target "style-check"; rule "style" says run, rule "docs-only" says skip; run wins`, + `018. clash on target "wait-for-images"; rule "image-wait" says run, rule "docs-only" says skip; run wins`, + `019. added target "should-dispatch" because target "wait-for-images" requires it`, + } { + assert.Contains(t, resolved.log, expected) + } + assert.NotContains(t, resolved.log, "because file") +} + +func TestResolverMixedCIToolingAndProduct(t *testing.T) { + m, err := loadMapping("testdata/rules.toml") + require.NoError(t, err) + text, err := os.ReadFile("testdata/defaults") + require.NoError(t, err) + d, err := parseDefaults(string(text)) + require.NoError(t, err) + resolved, err := resolve([]string{".github/workflows/style.yaml", "sensor/common/foo.go"}, nil, m, d, metadata{}) + require.NoError(t, err) + assert.True(t, resolved.jobs["e2e-byodb-tests"]) + assert.True(t, resolved.jobs["go"]) + assert.Contains(t, resolved.log, `rule "style" runs target "style-check"`) + assert.NotContains(t, resolved.log, `rule "ci-tooling" skips`) +} + +func TestResolverUnmatchedFileUsesDefaults(t *testing.T) { + m, err := loadMapping("../test-domains.toml") + require.NoError(t, err) + text, err := os.ReadFile("../decision-defaults") + require.NoError(t, err) + d, err := parseDefaults(string(text)) + require.NoError(t, err) + expected, err := parseDecision(d.decision()) + require.NoError(t, err) + resolved, err := resolve([]string{"notes/todo.txt"}, nil, m, d, metadata{}) + require.NoError(t, err) + assert.Equal(t, expected, resolved.jobs) +} + +func TestResolverCheckRequiresJob(t *testing.T) { + rules := fixturePath(t, `version = 1 +jobs = ["go", "go-check"] +[[rules]] +name = "matrix" +when = "always" +run = ["go-check"] +[[rules]] +name = "remaining" +when = "remaining" +default = ["*"] +[requires] +go-check = ["go"] +`) + m, err := loadMapping(rules) + require.NoError(t, err) + d, err := parseDefaults("go skip\ngo-check skip\n") + require.NoError(t, err) + resolved, err := resolve(nil, nil, m, d, metadata{}) + require.NoError(t, err) + assert.Equal(t, "go-check\ngo\n", resolved.decision) + assert.Equal(t, map[string]bool{"go": true, "go-check": true}, resolved.jobs) + assert.Contains(t, resolved.log, `002. added target "go" because target "go-check" requires it`) + assert.NotContains(t, resolved.log, `takes default "skip" for target "go"`) +} + +func TestOpenDecisionFallbacks(t *testing.T) { + for name, test := range map[string]struct { + rules string + files []string + unknown bool + decision, reason string + }{ + "empty known diff": { + rules: "testdata/corners.toml", decision: "unit\n", reason: "nothing-changed", + }, + "unknown diff": { + rules: "testdata/corners.toml", unknown: true, decision: "# no job runs\n", reason: "changed files are unknown", + }, + "invalid version": { + rules: "testdata/invalid/bad-version.toml", files: []string{"README.md"}, + decision: "# no job runs\n", reason: "unsupported rules version: 2", + }, + "missing rules": { + rules: "testdata/missing.toml", decision: "# no job runs\n", reason: "resolver failed:", + }, + "broken TOML": { + rules: "testdata/invalid/broken.toml", decision: "# no job runs\n", reason: "resolver failed:", + }, + } { + t.Run(name, func(t *testing.T) { + resolved, owned, err := openDecision(test.rules, "testdata/corners-defaults", + test.files, nil, test.unknown, metadata{}) + require.NoError(t, err) + assert.Equal(t, test.decision, resolved.decision) + jobs, err := parseDecision(test.decision) + require.NoError(t, err) + assert.Equal(t, jobs, resolved.jobs) + assert.Equal(t, "skip", owned.actions["unit"]) + assert.Contains(t, resolved.log, test.reason) + if name != "empty known diff" { + assert.Contains(t, resolved.log, "using decision-defaults") + } + }) + } +} + +func TestResolverDefaultsMismatch(t *testing.T) { + m, err := loadMapping("testdata/corners.toml") + require.NoError(t, err) + for name, text := range map[string]string{ + "missing target": "unit run\n", + "extra target": "unit skip\nlint skip\nsuite skip\nimages skip\nextra skip\nunowned run\n", + } { + t.Run(name, func(t *testing.T) { + d, err := parseDefaults(text) + require.NoError(t, err) + _, err = resolve(nil, nil, m, d, metadata{}) + require.Error(t, err) + if name == "missing target" { + assert.Contains(t, err.Error(), "decision-defaults is missing lint") + } else { + assert.Contains(t, err.Error(), "decision-defaults has unknown target unowned") + } + defaultsPath := fixturePath(t, text) + resolved, _, err := openDecision("testdata/corners.toml", defaultsPath, nil, nil, false, metadata{}) + require.NoError(t, err) + assert.Equal(t, d.decision(), resolved.decision) + assert.Contains(t, resolved.log, "using decision-defaults") + }) + } +} + +func TestOpenDecisionUnusableDefaults(t *testing.T) { + for name, path := range map[string]string{ + "missing": filepath.Join(t.TempDir(), "missing"), + "invalid": fixturePath(t, "unit maybe\n"), + } { + t.Run(name, func(t *testing.T) { + _, _, err := openDecision("testdata/corners.toml", path, nil, nil, false, metadata{}) + require.Error(t, err) + }) + } +} diff --git a/ci/select/testdata/corners-defaults b/ci/select/testdata/corners-defaults new file mode 100644 index 0000000000000..14c5e9c5fa8b4 --- /dev/null +++ b/ci/select/testdata/corners-defaults @@ -0,0 +1,5 @@ +unit skip +lint skip +suite skip +images skip +extra skip diff --git a/ci/select/testdata/corners.toml b/ci/select/testdata/corners.toml new file mode 100644 index 0000000000000..b62ea6261a07d --- /dev/null +++ b/ci/select/testdata/corners.toml @@ -0,0 +1,39 @@ +# Decision paths the sample rules do not contain. +# An empty diff is its own condition. A running suite pulls images, and +# images pulls extra. The catch-all skips whatever no earlier rule mentioned. + +version = 1 + +jobs = ["unit", "lint", "suite", "images", "extra"] + +[[rules]] +name = "nothing-changed" +when = "no-file-changed" +run = ["unit"] + +[[rules]] +name = "go-wildcard" +when = "any-file-matches" +paths = ['.*\.go$'] +run = ["unit"] + +[[rules]] +name = "shapes" +when = "any-file-matches" +paths = ['^keep/exact.go$', '^pkg/', 'notes'] +run = ["lint"] + +[[rules]] +name = "run-suite" +when = "label-exists" +label = "ci-suite" +run = ["suite"] + +[[rules]] +name = "remaining" +when = "remaining" +skip = ["*"] + +[requires] +suite = ["images"] +images = ["extra"] diff --git a/ci/select/testdata/defaults b/ci/select/testdata/defaults new file mode 100644 index 0000000000000..ed0eb1d63f836 --- /dev/null +++ b/ci/select/testdata/defaults @@ -0,0 +1,23 @@ +# One target per line. The last word is run or skip. +# The resolver uses this line when the catch-all rule says default. +# The dispatcher uses the whole file when the resolver fails. +# These lines are a ready pull request: not a draft, not a fork, and no +# opt-in label. A change that is only under ui/ is a separate filter. + +style-check run +go run +go-postgres run +sensor-integration-tests run +should-dispatch run +wait-for-images run +e2e-byodb-tests run +e2e-db-backup-restore-tests run +e2e-gke-upgrade-tests run +e2e-nongroovy-tests run +e2e-qa-tests-gke skip +e2e-qa-tests-gke-konflux skip +e2e-qa-tests-ocp-4-22 skip +gke-nongroovy-e2e-tests run +gke-qa-e2e-tests skip +gke-ui-e2e-tests run +ocp-vm-scanning-e2e-tests skip diff --git a/ci/select/testdata/defaults-go-postgres-skip b/ci/select/testdata/defaults-go-postgres-skip new file mode 100644 index 0000000000000..18159faddc08e --- /dev/null +++ b/ci/select/testdata/defaults-go-postgres-skip @@ -0,0 +1,23 @@ +# One target per line. The last word is run or skip. +# The resolver uses this line when the catch-all rule says default. +# The dispatcher uses the whole file when the resolver fails. +# These lines are a ready pull request: not a draft, not a fork, and no +# opt-in label. A change that is only under ui/ is a separate filter. + +style-check run +go run +go-postgres skip +sensor-integration-tests run +should-dispatch run +wait-for-images run +e2e-byodb-tests run +e2e-db-backup-restore-tests run +e2e-gke-upgrade-tests run +e2e-nongroovy-tests run +e2e-qa-tests-gke skip +e2e-qa-tests-gke-konflux skip +e2e-qa-tests-ocp-4-22 skip +gke-nongroovy-e2e-tests run +gke-qa-e2e-tests skip +gke-ui-e2e-tests run +ocp-vm-scanning-e2e-tests skip diff --git a/ci/select/testdata/edge_cases.json b/ci/select/testdata/edge_cases.json new file mode 100644 index 0000000000000..6b0256ca8dc4a --- /dev/null +++ b/ci/select/testdata/edge_cases.json @@ -0,0 +1,277 @@ +[ + { + "name": "A sensor file and a Central policy file should run go-postgres when its ready line says skip", + "input": { + "files": [ + "sensor/common/foo.go", + "central/policy/service.go" + ], + "labels": [], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults-go-postgres-skip" + }, + "expect": { + "decision": "style-check\ngo\ngo-postgres\nsensor-integration-tests\nshould-dispatch\nwait-for-images\ne2e-byodb-tests\ne2e-db-backup-restore-tests\ne2e-gke-upgrade-tests\ne2e-nongroovy-tests\ngke-nongroovy-e2e-tests\ngke-ui-e2e-tests\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"go-sources\" matches all 2 changed files\n sensor/common/foo.go\n central/policy/service.go\n002. rule \"go-sources\" runs target \"go\"\n003. rule \"style\" runs target \"style-check\"\n004. rule \"sensor\" matches\n sensor/common/foo.go\n005. rule \"central-policy\" matches\n central/policy/service.go\n006. rule \"image-wait\" runs target \"wait-for-images\"\n007. clash on target \"go-postgres\"; rule \"central-policy\" says run, rule \"sensor\" says skip; run wins\n008. clash on target \"sensor-integration-tests\"; rule \"sensor\" says run, rule \"central-policy\" says skip; run wins\n009. rule \"remaining\" takes default \"run\" for target \"should-dispatch\"\n010. rule \"remaining\" takes default \"run\" for target \"e2e-byodb-tests\"\n011. rule \"remaining\" takes default \"run\" for target \"e2e-db-backup-restore-tests\"\n012. rule \"remaining\" takes default \"run\" for target \"e2e-gke-upgrade-tests\"\n013. rule \"remaining\" takes default \"run\" for target \"e2e-nongroovy-tests\"\n014. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-gke\"\n015. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-gke-konflux\"\n016. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-ocp-4-22\"\n017. rule \"remaining\" takes default \"run\" for target \"gke-nongroovy-e2e-tests\"\n018. rule \"remaining\" takes default \"skip\" for target \"gke-qa-e2e-tests\"\n019. rule \"remaining\" takes default \"run\" for target \"gke-ui-e2e-tests\"\n020. rule \"remaining\" takes default \"skip\" for target \"ocp-vm-scanning-e2e-tests\"\n" + } + }, + { + "name": "A sensor file and a Central policy file should run both suites when both ready lines say run", + "input": { + "files": [ + "sensor/common/foo.go", + "central/policy/service.go" + ], + "labels": [], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults" + }, + "expect": { + "decision": "style-check\ngo\ngo-postgres\nsensor-integration-tests\nshould-dispatch\nwait-for-images\ne2e-byodb-tests\ne2e-db-backup-restore-tests\ne2e-gke-upgrade-tests\ne2e-nongroovy-tests\ngke-nongroovy-e2e-tests\ngke-ui-e2e-tests\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"go-sources\" matches all 2 changed files\n sensor/common/foo.go\n central/policy/service.go\n002. rule \"go-sources\" runs target \"go\"\n003. rule \"style\" runs target \"style-check\"\n004. rule \"sensor\" matches\n sensor/common/foo.go\n005. rule \"central-policy\" matches\n central/policy/service.go\n006. rule \"image-wait\" runs target \"wait-for-images\"\n007. clash on target \"go-postgres\"; rule \"central-policy\" says run, rule \"sensor\" says skip; run wins\n008. clash on target \"sensor-integration-tests\"; rule \"sensor\" says run, rule \"central-policy\" says skip; run wins\n009. rule \"remaining\" takes default \"run\" for target \"should-dispatch\"\n010. rule \"remaining\" takes default \"run\" for target \"e2e-byodb-tests\"\n011. rule \"remaining\" takes default \"run\" for target \"e2e-db-backup-restore-tests\"\n012. rule \"remaining\" takes default \"run\" for target \"e2e-gke-upgrade-tests\"\n013. rule \"remaining\" takes default \"run\" for target \"e2e-nongroovy-tests\"\n014. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-gke\"\n015. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-gke-konflux\"\n016. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-ocp-4-22\"\n017. rule \"remaining\" takes default \"run\" for target \"gke-nongroovy-e2e-tests\"\n018. rule \"remaining\" takes default \"skip\" for target \"gke-qa-e2e-tests\"\n019. rule \"remaining\" takes default \"run\" for target \"gke-ui-e2e-tests\"\n020. rule \"remaining\" takes default \"skip\" for target \"ocp-vm-scanning-e2e-tests\"\n" + } + }, + { + "name": "README.md alone should skip go and run style-check", + "input": { + "files": [ + "README.md" + ], + "labels": [], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults" + }, + "expect": { + "decision": "style-check\nwait-for-images\nshould-dispatch\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"docs-only\" matches every changed file\n README.md\n002. rule \"docs-only\" skips target \"go\"\n003. rule \"docs-only\" skips target \"go-postgres\"\n004. rule \"docs-only\" skips target \"sensor-integration-tests\"\n005. rule \"docs-only\" skips target \"should-dispatch\"\n006. rule \"docs-only\" skips target \"e2e-byodb-tests\"\n007. rule \"docs-only\" skips target \"e2e-db-backup-restore-tests\"\n008. rule \"docs-only\" skips target \"e2e-gke-upgrade-tests\"\n009. rule \"docs-only\" skips target \"e2e-nongroovy-tests\"\n010. rule \"docs-only\" skips target \"e2e-qa-tests-gke\"\n011. rule \"docs-only\" skips target \"e2e-qa-tests-gke-konflux\"\n012. rule \"docs-only\" skips target \"e2e-qa-tests-ocp-4-22\"\n013. rule \"docs-only\" skips target \"gke-nongroovy-e2e-tests\"\n014. rule \"docs-only\" skips target \"gke-qa-e2e-tests\"\n015. rule \"docs-only\" skips target \"gke-ui-e2e-tests\"\n016. rule \"docs-only\" skips target \"ocp-vm-scanning-e2e-tests\"\n017. clash on target \"style-check\"; rule \"style\" says run, rule \"docs-only\" says skip; run wins\n018. clash on target \"wait-for-images\"; rule \"image-wait\" says run, rule \"docs-only\" says skip; run wins\n019. added target \"should-dispatch\" because target \"wait-for-images\" requires it\n" + } + }, + { + "name": "README.md listed twice should skip go and run style-check the same way", + "input": { + "files": [ + "README.md", + "README.md" + ], + "labels": [], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults" + }, + "expect": { + "decision": "style-check\nwait-for-images\nshould-dispatch\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"docs-only\" matches every changed file\n README.md\n002. rule \"docs-only\" skips target \"go\"\n003. rule \"docs-only\" skips target \"go-postgres\"\n004. rule \"docs-only\" skips target \"sensor-integration-tests\"\n005. rule \"docs-only\" skips target \"should-dispatch\"\n006. rule \"docs-only\" skips target \"e2e-byodb-tests\"\n007. rule \"docs-only\" skips target \"e2e-db-backup-restore-tests\"\n008. rule \"docs-only\" skips target \"e2e-gke-upgrade-tests\"\n009. rule \"docs-only\" skips target \"e2e-nongroovy-tests\"\n010. rule \"docs-only\" skips target \"e2e-qa-tests-gke\"\n011. rule \"docs-only\" skips target \"e2e-qa-tests-gke-konflux\"\n012. rule \"docs-only\" skips target \"e2e-qa-tests-ocp-4-22\"\n013. rule \"docs-only\" skips target \"gke-nongroovy-e2e-tests\"\n014. rule \"docs-only\" skips target \"gke-qa-e2e-tests\"\n015. rule \"docs-only\" skips target \"gke-ui-e2e-tests\"\n016. rule \"docs-only\" skips target \"ocp-vm-scanning-e2e-tests\"\n017. clash on target \"style-check\"; rule \"style\" says run, rule \"docs-only\" says skip; run wins\n018. clash on target \"wait-for-images\"; rule \"image-wait\" says run, rule \"docs-only\" says skip; run wins\n019. added target \"should-dispatch\" because target \"wait-for-images\" requires it\n" + } + }, + { + "name": "A file no rule names should run the ready pull request and leave the skip lines off", + "input": { + "files": [ + "notes/todo.txt" + ], + "labels": [], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults" + }, + "expect": { + "decision": "style-check\ngo\ngo-postgres\nsensor-integration-tests\nshould-dispatch\nwait-for-images\ne2e-byodb-tests\ne2e-db-backup-restore-tests\ne2e-gke-upgrade-tests\ne2e-nongroovy-tests\ngke-nongroovy-e2e-tests\ngke-ui-e2e-tests\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"style\" runs target \"style-check\"\n002. rule \"image-wait\" runs target \"wait-for-images\"\n003. rule \"remaining\" takes default \"run\" for target \"go\"\n004. rule \"remaining\" takes default \"run\" for target \"go-postgres\"\n005. rule \"remaining\" takes default \"run\" for target \"sensor-integration-tests\"\n006. rule \"remaining\" takes default \"run\" for target \"should-dispatch\"\n007. rule \"remaining\" takes default \"run\" for target \"e2e-byodb-tests\"\n008. rule \"remaining\" takes default \"run\" for target \"e2e-db-backup-restore-tests\"\n009. rule \"remaining\" takes default \"run\" for target \"e2e-gke-upgrade-tests\"\n010. rule \"remaining\" takes default \"run\" for target \"e2e-nongroovy-tests\"\n011. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-gke\"\n012. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-gke-konflux\"\n013. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-ocp-4-22\"\n014. rule \"remaining\" takes default \"run\" for target \"gke-nongroovy-e2e-tests\"\n015. rule \"remaining\" takes default \"skip\" for target \"gke-qa-e2e-tests\"\n016. rule \"remaining\" takes default \"run\" for target \"gke-ui-e2e-tests\"\n017. rule \"remaining\" takes default \"skip\" for target \"ocp-vm-scanning-e2e-tests\"\n" + } + }, + { + "name": "ci-run-all-tests on a file no rule names should run every target", + "input": { + "files": [ + "notes/todo.txt" + ], + "labels": [ + "ci-run-all-tests" + ], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults" + }, + "expect": { + "decision": "style-check\ngo\ngo-postgres\nsensor-integration-tests\nshould-dispatch\nwait-for-images\ne2e-byodb-tests\ne2e-db-backup-restore-tests\ne2e-gke-upgrade-tests\ne2e-nongroovy-tests\ne2e-qa-tests-gke\ne2e-qa-tests-gke-konflux\ne2e-qa-tests-ocp-4-22\ngke-nongroovy-e2e-tests\ngke-qa-e2e-tests\ngke-ui-e2e-tests\nocp-vm-scanning-e2e-tests\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"run-all-label\" matches because label \"ci-run-all-tests\" is set\n002. rule \"run-all-label\" runs target \"style-check\"\n003. rule \"run-all-label\" runs target \"go\"\n004. rule \"run-all-label\" runs target \"go-postgres\"\n005. rule \"run-all-label\" runs target \"sensor-integration-tests\"\n006. rule \"run-all-label\" runs target \"should-dispatch\"\n007. rule \"run-all-label\" runs target \"wait-for-images\"\n008. rule \"run-all-label\" runs target \"e2e-byodb-tests\"\n009. rule \"run-all-label\" runs target \"e2e-db-backup-restore-tests\"\n010. rule \"run-all-label\" runs target \"e2e-gke-upgrade-tests\"\n011. rule \"run-all-label\" runs target \"e2e-nongroovy-tests\"\n012. rule \"run-all-label\" runs target \"e2e-qa-tests-gke\"\n013. rule \"run-all-label\" runs target \"e2e-qa-tests-gke-konflux\"\n014. rule \"run-all-label\" runs target \"e2e-qa-tests-ocp-4-22\"\n015. rule \"run-all-label\" runs target \"gke-nongroovy-e2e-tests\"\n016. rule \"run-all-label\" runs target \"gke-qa-e2e-tests\"\n017. rule \"run-all-label\" runs target \"gke-ui-e2e-tests\"\n018. rule \"run-all-label\" runs target \"ocp-vm-scanning-e2e-tests\"\n019. rule \"style\" runs target \"style-check\"\n020. rule \"image-wait\" runs target \"wait-for-images\"\n" + } + }, + { + "name": "ci-run-all-tests on README.md should run every target", + "input": { + "files": [ + "README.md" + ], + "labels": [ + "ci-run-all-tests" + ], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults" + }, + "expect": { + "decision": "style-check\ngo\ngo-postgres\nsensor-integration-tests\nshould-dispatch\nwait-for-images\ne2e-byodb-tests\ne2e-db-backup-restore-tests\ne2e-gke-upgrade-tests\ne2e-nongroovy-tests\ne2e-qa-tests-gke\ne2e-qa-tests-gke-konflux\ne2e-qa-tests-ocp-4-22\ngke-nongroovy-e2e-tests\ngke-qa-e2e-tests\ngke-ui-e2e-tests\nocp-vm-scanning-e2e-tests\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"run-all-label\" matches because label \"ci-run-all-tests\" is set\n002. rule \"docs-only\" matches every changed file\n README.md\n003. clash on target \"style-check\"; rule \"run-all-label\" and rule \"style\" say run, rule \"docs-only\" says skip; run wins\n004. clash on target \"go\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n005. clash on target \"go-postgres\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n006. clash on target \"sensor-integration-tests\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n007. clash on target \"should-dispatch\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n008. clash on target \"wait-for-images\"; rule \"run-all-label\" and rule \"image-wait\" say run, rule \"docs-only\" says skip; run wins\n009. clash on target \"e2e-byodb-tests\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n010. clash on target \"e2e-db-backup-restore-tests\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n011. clash on target \"e2e-gke-upgrade-tests\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n012. clash on target \"e2e-nongroovy-tests\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n013. clash on target \"e2e-qa-tests-gke\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n014. clash on target \"e2e-qa-tests-gke-konflux\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n015. clash on target \"e2e-qa-tests-ocp-4-22\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n016. clash on target \"gke-nongroovy-e2e-tests\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n017. clash on target \"gke-qa-e2e-tests\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n018. clash on target \"gke-ui-e2e-tests\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n019. clash on target \"ocp-vm-scanning-e2e-tests\"; rule \"run-all-label\" says run, rule \"docs-only\" says skip; run wins\n" + } + }, + { + "name": "ci-run-all-tests on CI tooling files should run the end-to-end jobs", + "input": { + "files": [ + ".github/workflows/ci-decision.yaml", + ".openshift-ci/dispatch.sh", + "ci/select/resolver.py" + ], + "labels": [ + "ci-run-all-tests" + ], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults" + }, + "expect": { + "decision": "style-check\ngo\ngo-postgres\nsensor-integration-tests\nshould-dispatch\nwait-for-images\ne2e-byodb-tests\ne2e-db-backup-restore-tests\ne2e-gke-upgrade-tests\ne2e-nongroovy-tests\ne2e-qa-tests-gke\ne2e-qa-tests-gke-konflux\ne2e-qa-tests-ocp-4-22\ngke-nongroovy-e2e-tests\ngke-qa-e2e-tests\ngke-ui-e2e-tests\nocp-vm-scanning-e2e-tests\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"run-all-label\" matches because label \"ci-run-all-tests\" is set\n002. rule \"run-all-label\" runs target \"style-check\"\n003. rule \"run-all-label\" runs target \"go\"\n004. rule \"run-all-label\" runs target \"go-postgres\"\n005. rule \"run-all-label\" runs target \"sensor-integration-tests\"\n006. rule \"run-all-label\" runs target \"should-dispatch\"\n007. rule \"run-all-label\" runs target \"wait-for-images\"\n008. rule \"ci-tooling\" matches every changed file\n .github/workflows/ (1)\n .openshift-ci/ (1)\n ci/select/ (1)\n009. rule \"style\" runs target \"style-check\"\n010. rule \"image-wait\" runs target \"wait-for-images\"\n011. clash on target \"e2e-byodb-tests\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n012. clash on target \"e2e-db-backup-restore-tests\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n013. clash on target \"e2e-gke-upgrade-tests\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n014. clash on target \"e2e-nongroovy-tests\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n015. clash on target \"e2e-qa-tests-gke\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n016. clash on target \"e2e-qa-tests-gke-konflux\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n017. clash on target \"e2e-qa-tests-ocp-4-22\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n018. clash on target \"gke-nongroovy-e2e-tests\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n019. clash on target \"gke-qa-e2e-tests\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n020. clash on target \"gke-ui-e2e-tests\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n021. clash on target \"ocp-vm-scanning-e2e-tests\"; rule \"run-all-label\" says run, rule \"ci-tooling\" says skip; run wins\n" + } + }, + { + "name": "An empty file list should run the ready pull request and leave the skip lines off", + "input": { + "files": [], + "labels": [], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults" + }, + "expect": { + "decision": "style-check\ngo\ngo-postgres\nsensor-integration-tests\nshould-dispatch\nwait-for-images\ne2e-byodb-tests\ne2e-db-backup-restore-tests\ne2e-gke-upgrade-tests\ne2e-nongroovy-tests\ngke-nongroovy-e2e-tests\ngke-ui-e2e-tests\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"style\" runs target \"style-check\"\n002. rule \"image-wait\" runs target \"wait-for-images\"\n003. rule \"remaining\" takes default \"run\" for target \"go\"\n004. rule \"remaining\" takes default \"run\" for target \"go-postgres\"\n005. rule \"remaining\" takes default \"run\" for target \"sensor-integration-tests\"\n006. rule \"remaining\" takes default \"run\" for target \"should-dispatch\"\n007. rule \"remaining\" takes default \"run\" for target \"e2e-byodb-tests\"\n008. rule \"remaining\" takes default \"run\" for target \"e2e-db-backup-restore-tests\"\n009. rule \"remaining\" takes default \"run\" for target \"e2e-gke-upgrade-tests\"\n010. rule \"remaining\" takes default \"run\" for target \"e2e-nongroovy-tests\"\n011. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-gke\"\n012. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-gke-konflux\"\n013. rule \"remaining\" takes default \"skip\" for target \"e2e-qa-tests-ocp-4-22\"\n014. rule \"remaining\" takes default \"run\" for target \"gke-nongroovy-e2e-tests\"\n015. rule \"remaining\" takes default \"skip\" for target \"gke-qa-e2e-tests\"\n016. rule \"remaining\" takes default \"run\" for target \"gke-ui-e2e-tests\"\n017. rule \"remaining\" takes default \"skip\" for target \"ocp-vm-scanning-e2e-tests\"\n" + } + }, + { + "name": "An unreadable file list should run the ready pull request without walking the rules", + "input": { + "files": null, + "labels": [], + "rules": "ci/select/testdata/rules.toml", + "defaults": "ci/select/testdata/defaults" + }, + "expect": { + "decision": "style-check\ngo\ngo-postgres\nsensor-integration-tests\nshould-dispatch\nwait-for-images\ne2e-byodb-tests\ne2e-db-backup-restore-tests\ne2e-gke-upgrade-tests\ne2e-nongroovy-tests\ngke-nongroovy-e2e-tests\ngke-ui-e2e-tests\n", + "log": "resolver failed: changed files are unknown\nusing decision-defaults\n" + } + }, + { + "name": "A suite that requires images should run images after a rule skipped them", + "input": { + "files": [], + "labels": [], + "rules": "\n version = 1\n jobs = [\"suite\", \"images\"]\n [[rules]]\n name = \"suite\"\n when = \"always\"\n run = [\"suite\"]\n [[rules]]\n name = \"hold-images\"\n when = \"always\"\n skip = [\"images\"]\n [[rules]]\n name = \"remaining\"\n when = \"remaining\"\n default = [\"*\"]\n [requires]\n suite = [\"images\"]\n ", + "defaults": "\n suite skip\n images skip\n " + }, + "expect": { + "decision": "suite\nimages\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"suite\" runs target \"suite\"\n002. rule \"hold-images\" skips target \"images\"\n003. added target \"images\" because target \"suite\" requires it\n" + } + }, + { + "name": "Rules where left and right require each other should be rejected", + "input": { + "rules": "\n version = 1\n jobs = [\"left\", \"right\"]\n [[rules]]\n name = \"run-left\"\n when = \"always\"\n run = [\"left\"]\n [[rules]]\n name = \"remaining\"\n when = \"remaining\"\n default = [\"*\"]\n [requires]\n left = [\"right\"]\n right = [\"left\"]\n " + }, + "expect": { + "error": "requires cycle at left" + } + }, + { + "name": "Sensor listed before central-policy should run both jobs", + "input": { + "files": [ + "sensor/common/foo.go", + "central/policy/service.go" + ], + "labels": [], + "rules": "\n version = 1\n jobs = [\"go-postgres\", \"sensor-integration-tests\"]\n [[rules]]\n name = \"sensor\"\n when = \"any-file-matches\"\n paths = ['^sensor/']\n run = [\"sensor-integration-tests\"]\n skip = [\"go-postgres\"]\n [[rules]]\n name = \"central-policy\"\n when = \"any-file-matches\"\n paths = ['^central/policy/']\n run = [\"go-postgres\"]\n skip = [\"sensor-integration-tests\"]\n [[rules]]\n name = \"remaining\"\n when = \"remaining\"\n default = [\"*\"]\n ", + "defaults": "\n go-postgres run\n sensor-integration-tests run\n " + }, + "expect": { + "decision": "go-postgres\nsensor-integration-tests\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"sensor\" matches\n sensor/common/foo.go\n002. rule \"central-policy\" matches\n central/policy/service.go\n003. clash on target \"go-postgres\"; rule \"central-policy\" says run, rule \"sensor\" says skip; run wins\n004. clash on target \"sensor-integration-tests\"; rule \"sensor\" says run, rule \"central-policy\" says skip; run wins\n" + } + }, + { + "name": "Central-policy listed before sensor should run both jobs", + "input": { + "files": [ + "sensor/common/foo.go", + "central/policy/service.go" + ], + "labels": [], + "rules": "\n version = 1\n jobs = [\"go-postgres\", \"sensor-integration-tests\"]\n [[rules]]\n name = \"central-policy\"\n when = \"any-file-matches\"\n paths = ['^central/policy/']\n run = [\"go-postgres\"]\n skip = [\"sensor-integration-tests\"]\n [[rules]]\n name = \"sensor\"\n when = \"any-file-matches\"\n paths = ['^sensor/']\n run = [\"sensor-integration-tests\"]\n skip = [\"go-postgres\"]\n [[rules]]\n name = \"remaining\"\n when = \"remaining\"\n default = [\"*\"]\n ", + "defaults": "\n go-postgres run\n sensor-integration-tests run\n " + }, + "expect": { + "decision": "go-postgres\nsensor-integration-tests\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"central-policy\" matches\n central/policy/service.go\n002. rule \"sensor\" matches\n sensor/common/foo.go\n003. clash on target \"go-postgres\"; rule \"central-policy\" says run, rule \"sensor\" says skip; run wins\n004. clash on target \"sensor-integration-tests\"; rule \"sensor\" says run, rule \"central-policy\" says skip; run wins\n" + } + }, + { + "name": "An empty file list should run unit because no file changed", + "input": { + "files": [], + "labels": [], + "rules": "ci/select/testdata/corners.toml", + "defaults": "ci/select/testdata/corners-defaults" + }, + "expect": { + "decision": "unit\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"nothing-changed\" matches because the diff has no files\n002. rule \"nothing-changed\" runs target \"unit\"\n" + } + }, + { + "name": "cmd/main.go should run unit because the wildcard matches that path", + "input": { + "files": [ + "cmd/main.go" + ], + "labels": [], + "rules": "ci/select/testdata/corners.toml", + "defaults": "ci/select/testdata/corners-defaults" + }, + "expect": { + "decision": "unit\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"go-wildcard\" matches\n cmd/main.go\n002. rule \"go-wildcard\" runs target \"unit\"\n" + } + }, + { + "name": "An exact path, a directory prefix, and two notes files should run lint", + "input": { + "files": [ + "keep/exact.go", + "pkg/a.go", + "pkg/b.go", + "notes/a", + "notes/b" + ], + "labels": [], + "rules": "ci/select/testdata/corners.toml", + "defaults": "ci/select/testdata/corners-defaults" + }, + "expect": { + "decision": "unit\nlint\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"go-wildcard\" matches\n keep/exact.go\n pkg/a.go\n pkg/b.go\n002. rule \"go-wildcard\" runs target \"unit\"\n003. rule \"shapes\" matches all 5 changed files\n keep/exact.go\n pkg/ (2)\n notes/a\n notes/b\n004. rule \"shapes\" runs target \"lint\"\n" + } + }, + { + "name": "The ci-suite label should run suite and the jobs that chain requires", + "input": { + "files": [ + "README.md" + ], + "labels": [ + "ci-suite" + ], + "rules": "ci/select/testdata/corners.toml", + "defaults": "ci/select/testdata/corners-defaults" + }, + "expect": { + "decision": "suite\nimages\nextra\n", + "log": "# stackrox/stackrox PR 23035 a1b2c3d\n# \n001. rule \"run-suite\" matches because label \"ci-suite\" is set\n002. rule \"run-suite\" runs target \"suite\"\n003. added target \"images\" because target \"suite\" requires it\n004. added target \"extra\" because target \"images\" requires it\n" + } + } +] diff --git a/ci/select/testdata/invalid/bad-pattern.toml b/ci/select/testdata/invalid/bad-pattern.toml new file mode 100644 index 0000000000000..27a7cbe410910 --- /dev/null +++ b/ci/select/testdata/invalid/bad-pattern.toml @@ -0,0 +1,11 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "any-file-matches" +paths = ['['] +run = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/bad-version.toml b/ci/select/testdata/invalid/bad-version.toml new file mode 100644 index 0000000000000..6f5f7e8612811 --- /dev/null +++ b/ci/select/testdata/invalid/bad-version.toml @@ -0,0 +1,6 @@ +version = 2 +jobs = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/broken.toml b/ci/select/testdata/invalid/broken.toml new file mode 100644 index 0000000000000..38d45e28fdb37 --- /dev/null +++ b/ci/select/testdata/invalid/broken.toml @@ -0,0 +1 @@ +version = [ diff --git a/ci/select/testdata/invalid/duplicate-job.toml b/ci/select/testdata/invalid/duplicate-job.toml new file mode 100644 index 0000000000000..ece9137404417 --- /dev/null +++ b/ci/select/testdata/invalid/duplicate-job.toml @@ -0,0 +1,6 @@ +version = 1 +jobs = ["unit", "unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/duplicate-rule-name.toml b/ci/select/testdata/invalid/duplicate-rule-name.toml new file mode 100644 index 0000000000000..2c250bab52162 --- /dev/null +++ b/ci/select/testdata/invalid/duplicate-rule-name.toml @@ -0,0 +1,10 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "same" +when = "always" +run = ["unit"] +[[rules]] +name = "same" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/jobs-not-a-list.toml b/ci/select/testdata/invalid/jobs-not-a-list.toml new file mode 100644 index 0000000000000..1cce5a1318c70 --- /dev/null +++ b/ci/select/testdata/invalid/jobs-not-a-list.toml @@ -0,0 +1,2 @@ +version = 1 +jobs = "unit" diff --git a/ci/select/testdata/invalid/label-not-a-string.toml b/ci/select/testdata/invalid/label-not-a-string.toml new file mode 100644 index 0000000000000..b91e2a77da128 --- /dev/null +++ b/ci/select/testdata/invalid/label-not-a-string.toml @@ -0,0 +1,11 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "label-exists" +label = 1 +run = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/label-not-allowed.toml b/ci/select/testdata/invalid/label-not-allowed.toml new file mode 100644 index 0000000000000..1290ce34d240b --- /dev/null +++ b/ci/select/testdata/invalid/label-not-allowed.toml @@ -0,0 +1,11 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +label = "ci-suite" +run = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/label-required.toml b/ci/select/testdata/invalid/label-required.toml new file mode 100644 index 0000000000000..ca74efc809003 --- /dev/null +++ b/ci/select/testdata/invalid/label-required.toml @@ -0,0 +1,10 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "label-exists" +run = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/last-rule-not-remaining.toml b/ci/select/testdata/invalid/last-rule-not-remaining.toml new file mode 100644 index 0000000000000..348d82d6dfa33 --- /dev/null +++ b/ci/select/testdata/invalid/last-rule-not-remaining.toml @@ -0,0 +1,6 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "only" +when = "always" +run = ["*"] diff --git a/ci/select/testdata/invalid/last-rule-not-star.toml b/ci/select/testdata/invalid/last-rule-not-star.toml new file mode 100644 index 0000000000000..a5976672d71b4 --- /dev/null +++ b/ci/select/testdata/invalid/last-rule-not-star.toml @@ -0,0 +1,6 @@ +version = 1 +jobs = ["unit", "lint"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["unit"] diff --git a/ci/select/testdata/invalid/missing-name.toml b/ci/select/testdata/invalid/missing-name.toml new file mode 100644 index 0000000000000..dc2a407c61ee1 --- /dev/null +++ b/ci/select/testdata/invalid/missing-name.toml @@ -0,0 +1,6 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "" +when = "always" +run = ["*"] diff --git a/ci/select/testdata/invalid/no-jobs.toml b/ci/select/testdata/invalid/no-jobs.toml new file mode 100644 index 0000000000000..45ac09dbd682e --- /dev/null +++ b/ci/select/testdata/invalid/no-jobs.toml @@ -0,0 +1,6 @@ +version = 1 +jobs = [] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/no-opinion.toml b/ci/select/testdata/invalid/no-opinion.toml new file mode 100644 index 0000000000000..f184b2d74f093 --- /dev/null +++ b/ci/select/testdata/invalid/no-opinion.toml @@ -0,0 +1,9 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/no-rules.toml b/ci/select/testdata/invalid/no-rules.toml new file mode 100644 index 0000000000000..b723f4239e7e1 --- /dev/null +++ b/ci/select/testdata/invalid/no-rules.toml @@ -0,0 +1,2 @@ +version = 1 +jobs = ["unit"] diff --git a/ci/select/testdata/invalid/paths-not-allowed.toml b/ci/select/testdata/invalid/paths-not-allowed.toml new file mode 100644 index 0000000000000..a1705bc846c1e --- /dev/null +++ b/ci/select/testdata/invalid/paths-not-allowed.toml @@ -0,0 +1,11 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +paths = ['^pkg/'] +run = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/paths-not-strings.toml b/ci/select/testdata/invalid/paths-not-strings.toml new file mode 100644 index 0000000000000..b7441f5522a4a --- /dev/null +++ b/ci/select/testdata/invalid/paths-not-strings.toml @@ -0,0 +1,11 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "any-file-matches" +paths = [1] +run = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/paths-required.toml b/ci/select/testdata/invalid/paths-required.toml new file mode 100644 index 0000000000000..f10490078860e --- /dev/null +++ b/ci/select/testdata/invalid/paths-required.toml @@ -0,0 +1,10 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "any-file-matches" +run = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/remaining-not-last.toml b/ci/select/testdata/invalid/remaining-not-last.toml new file mode 100644 index 0000000000000..ab0a36ade1305 --- /dev/null +++ b/ci/select/testdata/invalid/remaining-not-last.toml @@ -0,0 +1,14 @@ +version = 1 +jobs = ["unit", "lint"] +[[rules]] +name = "early" +when = "remaining" +skip = ["*"] +[[rules]] +name = "late" +when = "always" +run = ["lint"] +[[rules]] +name = "remaining" +when = "remaining" +run = ["*"] diff --git a/ci/select/testdata/invalid/repeated-job.toml b/ci/select/testdata/invalid/repeated-job.toml new file mode 100644 index 0000000000000..dedbd57dbfb67 --- /dev/null +++ b/ci/select/testdata/invalid/repeated-job.toml @@ -0,0 +1,10 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +run = ["unit", "unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/requires-duplicate.toml b/ci/select/testdata/invalid/requires-duplicate.toml new file mode 100644 index 0000000000000..2947f558ef63e --- /dev/null +++ b/ci/select/testdata/invalid/requires-duplicate.toml @@ -0,0 +1,8 @@ +version = 1 +jobs = ["unit", "lint"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] +[requires] +unit = ["lint", "lint"] diff --git a/ci/select/testdata/invalid/requires-empty.toml b/ci/select/testdata/invalid/requires-empty.toml new file mode 100644 index 0000000000000..1947a51a2118e --- /dev/null +++ b/ci/select/testdata/invalid/requires-empty.toml @@ -0,0 +1,8 @@ +version = 1 +jobs = ["unit", "lint"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] +[requires] +unit = [] diff --git a/ci/select/testdata/invalid/requires-missing-target.toml b/ci/select/testdata/invalid/requires-missing-target.toml new file mode 100644 index 0000000000000..c24b7c3d02145 --- /dev/null +++ b/ci/select/testdata/invalid/requires-missing-target.toml @@ -0,0 +1,8 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] +[requires] +unit = ["missing"] diff --git a/ci/select/testdata/invalid/requires-not-a-table.toml b/ci/select/testdata/invalid/requires-not-a-table.toml new file mode 100644 index 0000000000000..5758cb46f8d2d --- /dev/null +++ b/ci/select/testdata/invalid/requires-not-a-table.toml @@ -0,0 +1,8 @@ +version = 1 +jobs = ["unit"] +requires = [] + +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/requires-self.toml b/ci/select/testdata/invalid/requires-self.toml new file mode 100644 index 0000000000000..b123594c90fec --- /dev/null +++ b/ci/select/testdata/invalid/requires-self.toml @@ -0,0 +1,8 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] +[requires] +unit = ["unit"] diff --git a/ci/select/testdata/invalid/requires-unknown-job.toml b/ci/select/testdata/invalid/requires-unknown-job.toml new file mode 100644 index 0000000000000..89360e1bfdd60 --- /dev/null +++ b/ci/select/testdata/invalid/requires-unknown-job.toml @@ -0,0 +1,8 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] +[requires] +missing = ["unit"] diff --git a/ci/select/testdata/invalid/rule-not-a-table.toml b/ci/select/testdata/invalid/rule-not-a-table.toml new file mode 100644 index 0000000000000..c707f517e3276 --- /dev/null +++ b/ci/select/testdata/invalid/rule-not-a-table.toml @@ -0,0 +1,3 @@ +version = 1 +jobs = ["unit"] +rules = ["nope"] diff --git a/ci/select/testdata/invalid/run-not-a-list.toml b/ci/select/testdata/invalid/run-not-a-list.toml new file mode 100644 index 0000000000000..2876257f7d988 --- /dev/null +++ b/ci/select/testdata/invalid/run-not-a-list.toml @@ -0,0 +1,10 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +run = [1] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/star-and-names.toml b/ci/select/testdata/invalid/star-and-names.toml new file mode 100644 index 0000000000000..d1053e4c61e61 --- /dev/null +++ b/ci/select/testdata/invalid/star-and-names.toml @@ -0,0 +1,11 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +run = ["*"] +skip = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/star-mixed-in-list.toml b/ci/select/testdata/invalid/star-mixed-in-list.toml new file mode 100644 index 0000000000000..b3993fedc7dfa --- /dev/null +++ b/ci/select/testdata/invalid/star-mixed-in-list.toml @@ -0,0 +1,10 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +run = ["*", "unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/star-twice.toml b/ci/select/testdata/invalid/star-twice.toml new file mode 100644 index 0000000000000..b4a4d7546f586 --- /dev/null +++ b/ci/select/testdata/invalid/star-twice.toml @@ -0,0 +1,11 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +run = ["*"] +skip = ["*"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/two-opinions.toml b/ci/select/testdata/invalid/two-opinions.toml new file mode 100644 index 0000000000000..07328ebd20090 --- /dev/null +++ b/ci/select/testdata/invalid/two-opinions.toml @@ -0,0 +1,11 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +run = ["unit"] +skip = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/unknown-job.toml b/ci/select/testdata/invalid/unknown-job.toml new file mode 100644 index 0000000000000..b109bcd75708e --- /dev/null +++ b/ci/select/testdata/invalid/unknown-job.toml @@ -0,0 +1,10 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "always" +run = ["missing"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/invalid/unknown-when.toml b/ci/select/testdata/invalid/unknown-when.toml new file mode 100644 index 0000000000000..d399806909eac --- /dev/null +++ b/ci/select/testdata/invalid/unknown-when.toml @@ -0,0 +1,10 @@ +version = 1 +jobs = ["unit"] +[[rules]] +name = "odd" +when = "sometimes" +run = ["unit"] +[[rules]] +name = "rest" +when = "remaining" +skip = ["*"] diff --git a/ci/select/testdata/rejected_rules.json b/ci/select/testdata/rejected_rules.json new file mode 100644 index 0000000000000..2591d8cea53d3 --- /dev/null +++ b/ci/select/testdata/rejected_rules.json @@ -0,0 +1,299 @@ +[ + { + "name": "A rules file whose version is not 1 should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/bad-version.toml" + }, + "expect": { + "error": "unsupported rules version: 2" + } + }, + { + "name": "A rules file that names a job twice should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/duplicate-job.toml" + }, + "expect": { + "error": "duplicate job name" + } + }, + { + "name": "A rules file with no jobs should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/no-jobs.toml" + }, + "expect": { + "error": "jobs must list at least one target" + } + }, + { + "name": "A rules file whose jobs value is not a list should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/jobs-not-a-list.toml" + }, + "expect": { + "error": "jobs must be a list of strings" + } + }, + { + "name": "A rules file with no rules should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/no-rules.toml" + }, + "expect": { + "error": "rules must be a non-empty list" + } + }, + { + "name": "A rule that is not a table should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/rule-not-a-table.toml" + }, + "expect": { + "error": "rule 1 must be a table" + } + }, + { + "name": "Two rules with the same name should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/duplicate-rule-name.toml" + }, + "expect": { + "error": "duplicate rule name same" + } + }, + { + "name": "A rules file whose last rule is not remaining should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/last-rule-not-remaining.toml" + }, + "expect": { + "error": "the last rule must decide every target no earlier rule decided" + } + }, + { + "name": "A remaining rule that is not last should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/remaining-not-last.toml" + }, + "expect": { + "error": "rule 1 early uses remaining, which only the last rule may use" + } + }, + { + "name": "A remaining rule that does not use a star should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/last-rule-not-star.toml" + }, + "expect": { + "error": "the last rule must run, skip, or take the default for every remaining target" + } + }, + { + "name": "A rule with an empty name should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/missing-name.toml" + }, + "expect": { + "error": "rule 1 needs a name" + } + }, + { + "name": "A rule with an unknown when should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/unknown-when.toml" + }, + "expect": { + "error": "rule 1 odd has unknown when: 'sometimes'" + } + }, + { + "name": "A label that is not text should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/label-not-a-string.toml" + }, + "expect": { + "error": "rule 1 odd label must be a string" + } + }, + { + "name": "A rule that does not run, skip, or take the default should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/no-opinion.toml" + }, + "expect": { + "error": "rule 1 odd must run, skip, or take the default" + } + }, + { + "name": "A rule that uses a star twice should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/star-twice.toml" + }, + "expect": { + "error": "rule 1 odd uses * more than once" + } + }, + { + "name": "A rule that mixes a star with job names should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/star-and-names.toml" + }, + "expect": { + "error": "rule 1 odd mixes * with job names" + } + }, + { + "name": "A rule that runs and skips the same job should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/two-opinions.toml" + }, + "expect": { + "error": "rule 1 odd gives one job two opinions" + } + }, + { + "name": "A path rule without paths should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/paths-required.toml" + }, + "expect": { + "error": "rule 1 odd needs paths" + } + }, + { + "name": "A label rule without a label should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/label-required.toml" + }, + "expect": { + "error": "rule 1 odd needs a label" + } + }, + { + "name": "An always rule that lists paths should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/paths-not-allowed.toml" + }, + "expect": { + "error": "rule 1 odd does not take paths" + } + }, + { + "name": "An always rule that lists a label should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/label-not-allowed.toml" + }, + "expect": { + "error": "rule 1 odd does not take a label" + } + }, + { + "name": "A run list that is not job names should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/run-not-a-list.toml" + }, + "expect": { + "error": "rule 1 odd run must be a list of jobs" + } + }, + { + "name": "A run list that mixes a star with a job name should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/star-mixed-in-list.toml" + }, + "expect": { + "error": "rule 1 odd run mixes * with job names" + } + }, + { + "name": "A rule that names an unknown job should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/unknown-job.toml" + }, + "expect": { + "error": "unknown job missing in rule 1 odd" + } + }, + { + "name": "A rule that repeats a job should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/repeated-job.toml" + }, + "expect": { + "error": "rule 1 odd repeats a job in run" + } + }, + { + "name": "A requires value that is not a table should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/requires-not-a-table.toml" + }, + "expect": { + "error": "requires must be a table" + } + }, + { + "name": "A requires entry for an unknown job should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/requires-unknown-job.toml" + }, + "expect": { + "error": "unknown job missing in requires" + } + }, + { + "name": "A requires list that is empty should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/requires-empty.toml" + }, + "expect": { + "error": "unit requires a non-empty list" + } + }, + { + "name": "A requires list that repeats a job should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/requires-duplicate.toml" + }, + "expect": { + "error": "duplicate requirement for unit" + } + }, + { + "name": "A requires list that names an unknown job should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/requires-missing-target.toml" + }, + "expect": { + "error": "unknown job missing required by unit" + } + }, + { + "name": "A job that requires itself should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/requires-self.toml" + }, + "expect": { + "error": "unit requires itself" + } + }, + { + "name": "A paths value that is not a list of text should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/paths-not-strings.toml" + }, + "expect": { + "error": "rule 1 odd paths must be a list of strings" + } + }, + { + "name": "A path pattern that is not a regular expression should be rejected", + "input": { + "rules": "ci/select/testdata/invalid/bad-pattern.toml" + }, + "expect": { + "error": "invalid pattern '[' in rule 1 odd" + } + } +] diff --git a/ci/select/testdata/rules.toml b/ci/select/testdata/rules.toml new file mode 100644 index 0000000000000..3c5cc476cd49e --- /dev/null +++ b/ci/select/testdata/rules.toml @@ -0,0 +1,120 @@ +# Rules are walked from top to bottom. +# One rule saying run and another saying skip runs the target. The last +# rule covers every target that no earlier rule decided. default = ["*"] +# reads ci/decision-defaults. + +version = 1 + +jobs = [ + "style-check", + "go", + "go-postgres", + "sensor-integration-tests", + "should-dispatch", + "wait-for-images", + "e2e-byodb-tests", + "e2e-db-backup-restore-tests", + "e2e-gke-upgrade-tests", + "e2e-nongroovy-tests", + "e2e-qa-tests-gke", + "e2e-qa-tests-gke-konflux", + "e2e-qa-tests-ocp-4-22", + "gke-nongroovy-e2e-tests", + "gke-qa-e2e-tests", + "gke-ui-e2e-tests", + "ocp-vm-scanning-e2e-tests", +] + +# The label votes run for every target. A skip from another rule clashes, +# and that clash runs the job. +[[rules]] +name = "run-all-label" +when = "label-exists" +label = "ci-run-all-tests" +run = ["*"] + +# These files can change code the path rules do not name. +[[rules]] +name = "tree-may-have-changed" +when = "any-file-matches" +paths = ['^go\.mod$', '^go\.sum$', '^proto/', '^generated/'] +run = ["*"] + +# A docs-only change skips every target. style-check and wait-for-images +# still get a run vote, so those clashes run. +[[rules]] +name = "docs-only" +when = "every-file-matches" +paths = ['\.md$', '^docs/', '^CHANGELOG'] +skip = ["*"] + +# Workflow hooks and the selector do not change the product under test. +# The rule applies only when every changed file is one of these paths. +[[rules]] +name = "ci-tooling" +when = "every-file-matches" +paths = [ + '^\.github/workflows/', + '^\.openshift-ci/', + '^ci/select/', + '^ci/decision-defaults$', + '^ci/test-domains\.toml$', +] +skip = [ + "e2e-byodb-tests", + "e2e-db-backup-restore-tests", + "e2e-gke-upgrade-tests", + "e2e-nongroovy-tests", + "e2e-qa-tests-gke", + "e2e-qa-tests-gke-konflux", + "e2e-qa-tests-ocp-4-22", + "gke-nongroovy-e2e-tests", + "gke-qa-e2e-tests", + "gke-ui-e2e-tests", + "ocp-vm-scanning-e2e-tests", +] + +[[rules]] +name = "go-sources" +when = "any-file-matches" +paths = ['\.go$'] +run = ["go"] + +[[rules]] +name = "style" +when = "always" +run = ["style-check"] + +[[rules]] +name = "sensor" +when = "any-file-matches" +paths = ['^sensor/'] +run = ["sensor-integration-tests"] +skip = ["go-postgres"] + +[[rules]] +name = "central-policy" +when = "any-file-matches" +paths = ['^central/policy/'] +run = ["go-postgres"] +skip = ["sensor-integration-tests"] + +[[rules]] +name = "image-wait" +when = "always" +run = ["wait-for-images"] + +[[rules]] +name = "remaining" +when = "remaining" +default = ["*"] + +[requires] +wait-for-images = ["should-dispatch"] +e2e-byodb-tests = ["wait-for-images"] +e2e-db-backup-restore-tests = ["wait-for-images"] +e2e-gke-upgrade-tests = ["wait-for-images"] +e2e-nongroovy-tests = ["wait-for-images"] +e2e-qa-tests-gke = ["wait-for-images"] +e2e-qa-tests-ocp-4-22 = ["wait-for-images"] +e2e-qa-tests-gke-konflux = ["wait-for-images"] diff --git a/ci/test-domains.toml b/ci/test-domains.toml new file mode 100644 index 0000000000000..6a9062d94f2cf --- /dev/null +++ b/ci/test-domains.toml @@ -0,0 +1,371 @@ +# Rules are walked from top to bottom. +# One rule saying run and another saying skip runs the target. The last +# rule covers every target that no earlier rule decided. default = ["*"] +# reads ci/decision-defaults. + +version = 1 + +jobs = [ + "style-check", + "go", + "go-postgres", + "sensor-integration-tests", + "should-dispatch", + "wait-for-images", + "e2e-byodb-tests", + "e2e-db-backup-restore-tests", + "e2e-gke-upgrade-tests", + "e2e-nongroovy-tests", + "e2e-qa-tests-gke", + "e2e-qa-tests-gke-konflux", + "e2e-qa-tests-ocp-4-22", + "gke-nongroovy-e2e-tests", + "gke-qa-e2e-tests", + "gke-ui-e2e-tests", + "ocp-vm-scanning-e2e-tests", + "check-generated-files", + "check-crd-compatibility", + "golangci-lint", + "github-actions-lint", + "openshift-ci-lint", + "shell-unit-tests", + "ui", + "ui-component", + "local-roxctl-tests", + "db-integration-tests", +] + +# Tier 1: Label Overrides (highest priority) +# The label votes run for every target. A skip from another rule clashes, +# and that clash runs the job. +[[rules]] +name = "run-all-label" +when = "label-exists" +label = "ci-run-all-tests" +run = ["*"] + +[[rules]] +name = "area-label-sensor" +when = "label-exists" +label = "area/sensor" +run = ["sensor-integration-tests"] + +[[rules]] +name = "area-label-central" +when = "label-exists" +label = "area/central" +run = ["go-postgres"] + +[[rules]] +name = "area-label-scanner" +when = "label-exists" +label = "area/scanner" +run = ["db-integration-tests"] + +[[rules]] +name = "area-label-ui" +when = "label-exists" +label = "area/ui" +run = ["ui", "ui-component", "gke-ui-e2e-tests"] + +[[rules]] +name = "area-label-operator" +when = "label-exists" +label = "area/operator" +run = ["check-crd-compatibility"] + +[[rules]] +name = "area-label-roxctl" +when = "label-exists" +label = "area/roxctl" +run = ["local-roxctl-tests"] + +# Tier 2: High-Impact Changes (run everything) +# These files can change code the path rules do not name. +[[rules]] +name = "tree-may-have-changed" +when = "any-file-matches" +paths = ['^go\.mod$', '^go\.sum$', '^proto/', '^generated/'] +run = ["*"] + +# Tier 3: Docs-Only (aggressive skip) +# A docs-only change skips almost everything. style-check still runs due to clash with always-run rule. +[[rules]] +name = "docs-only" +when = "every-file-matches" +paths = ['\.md$', '^docs/', '^CHANGELOG', '^README'] +skip = [ + "go", + "go-postgres", + "sensor-integration-tests", + "db-integration-tests", + "check-generated-files", + "check-crd-compatibility", + "golangci-lint", + "github-actions-lint", + "openshift-ci-lint", + "ui", + "ui-component", + "local-roxctl-tests", + "shell-unit-tests", + "e2e-byodb-tests", + "e2e-db-backup-restore-tests", + "e2e-gke-upgrade-tests", + "e2e-nongroovy-tests", + "e2e-qa-tests-gke", + "e2e-qa-tests-gke-konflux", + "e2e-qa-tests-ocp-4-22", + "gke-nongroovy-e2e-tests", + "gke-qa-e2e-tests", + "gke-ui-e2e-tests", + "ocp-vm-scanning-e2e-tests", +] + +# Tier 4: CI Tooling Changes +# Workflow hooks and the selector do not change the product under test. +[[rules]] +name = "ci-tooling" +when = "every-file-matches" +paths = [ + '^\.github/workflows/', + '^\.openshift-ci/', + '^ci/select/', + '^ci/decision-defaults$', + '^ci/test-domains\.toml$', +] +skip = [ + "e2e-byodb-tests", + "e2e-db-backup-restore-tests", + "e2e-gke-upgrade-tests", + "e2e-nongroovy-tests", + "e2e-qa-tests-gke", + "e2e-qa-tests-gke-konflux", + "e2e-qa-tests-ocp-4-22", + "gke-nongroovy-e2e-tests", + "gke-qa-e2e-tests", + "gke-ui-e2e-tests", + "ocp-vm-scanning-e2e-tests", + "db-integration-tests", + "go-postgres", + "sensor-integration-tests", +] + +# Tier 5: Always-Run Tests +[[rules]] +name = "style" +when = "always" +run = ["style-check"] + +[[rules]] +name = "shell-tests" +when = "any-file-matches" +paths = ['\.sh$', '^scripts/', '^tests/.*\.sh$'] +run = ["shell-unit-tests"] + +[[rules]] +name = "github-actions-linting" +when = "any-file-matches" +paths = ['^\.github/workflows/'] +run = ["github-actions-lint"] + +[[rules]] +name = "openshift-ci-linting" +when = "any-file-matches" +paths = ['^\.openshift-ci/'] +run = ["openshift-ci-lint"] + +# Tier 6: Go Code & Linting +[[rules]] +name = "go-sources" +when = "any-file-matches" +paths = ['\.go$'] +run = ["go"] + +[[rules]] +name = "go-linting" +when = "any-file-matches" +paths = ['\.go$', '^\.golangci\.yml$'] +run = ["golangci-lint"] + +[[rules]] +name = "skip-golangci-for-non-go" +when = "every-file-matches" +paths = ['^ui/', '^docs/', '\.md$', '^\.github/', '^\.openshift-ci/', '^scripts/ci/'] +skip = ["golangci-lint"] + +# Tier 7: Generated Files +[[rules]] +name = "generated-files" +when = "any-file-matches" +paths = ['^proto/', '^generated/', '\.proto$'] +run = ["*"] + +# Tier 8: Subsystem-Specific Rules + +# UI-Only +[[rules]] +name = "ui-only" +when = "every-file-matches" +paths = ['^ui/'] +run = ["ui", "ui-component", "gke-ui-e2e-tests"] +skip = [ + "go", + "sensor-integration-tests", + "go-postgres", + "db-integration-tests", + "check-generated-files", + "check-crd-compatibility", + "golangci-lint", + "github-actions-lint", + "openshift-ci-lint", + "local-roxctl-tests", + "shell-unit-tests", + "e2e-byodb-tests", + "e2e-db-backup-restore-tests", + "e2e-gke-upgrade-tests", + "e2e-nongroovy-tests", + "gke-nongroovy-e2e-tests", + "gke-qa-e2e-tests", + "e2e-qa-tests-gke", + "e2e-qa-tests-gke-konflux", + "e2e-qa-tests-ocp-4-22", + "ocp-vm-scanning-e2e-tests", +] + +# Scanner-Only +[[rules]] +name = "scanner-only" +when = "every-file-matches" +paths = ['^scanner/'] +run = ["db-integration-tests", "go"] +skip = ["sensor-integration-tests", "go-postgres", "e2e-gke-upgrade-tests", "e2e-db-backup-restore-tests", "e2e-byodb-tests"] + +# Operator/CRD-Only +[[rules]] +name = "operator-only" +when = "every-file-matches" +paths = ['^operator/', '\.crd\.yaml$', '^config-controller/'] +run = ["check-crd-compatibility", "go"] +skip = ["sensor-integration-tests", "go-postgres", "db-integration-tests", "e2e-nongroovy-tests", "gke-nongroovy-e2e-tests"] + +# roxctl-Only +[[rules]] +name = "roxctl-only" +when = "every-file-matches" +paths = ['^roxctl/'] +run = ["local-roxctl-tests", "go", "e2e-nongroovy-tests"] +skip = ["sensor-integration-tests", "go-postgres", "db-integration-tests", "gke-ui-e2e-tests", "e2e-byodb-tests", "e2e-db-backup-restore-tests", "e2e-gke-upgrade-tests"] + +# Tier 9: Central/Sensor Separation (Aggressive) + +# Central-Only Paths +[[rules]] +name = "central-only-domains" +when = "every-file-matches" +paths = [ + '^central/(sac|search|auth|authprovider|apitoken|user|group|role|rbac|jwt|serviceidentities)/', + '^central/(postgres|globaldb|globalindex|graphdb|dackbox|blob|views)/', + '^central/(notifier|notifiers|reports|reportconfigurations)/', + '^central/(externalbackups|declarativeconfig|config|installation)/', + '^central/(ui|graphql|grpc|endpoints)/', + '^central/(featureflags|development|audit|logimbue|trace)/', + '^central/(helmcharts|ping|version|systeminfo|docs|internal)/', + '^pkg/(sac|search|postgres|notifiers|migrations|renderer|backup)/', +] +run = ["go-postgres"] +skip = ["sensor-integration-tests"] + +# Sensor-Only Paths +[[rules]] +name = "sensor-only-domains" +when = "every-file-matches" +paths = [ + '^sensor/(admission-control|upgrader|debugger|testutils|tests)/', + '^sensor/kubernetes/(fake|client|enforcer)/', + '^pkg/(admissioncontrol|docker|enforcers|complianceoperator|k8sintrospect|protocompat|compliance/checks|binenc)/', +] +run = ["sensor-integration-tests"] +skip = ["go-postgres"] + +# Central-Sensor Shared Paths (run both) +[[rules]] +name = "central-sensor-shared-pkg" +when = "any-file-matches" +paths = [ + '^pkg/(grpc|sync|concurrency|features|logging|utils|set|retry|env|errorhelpers|testutils|mtls|x509utils|buildinfo|version|cryptoutils|urlfmt|stringutils|timestamps|cloudproviders|cloudsources|roxctl)/', + '^pkg/(centralsensor|kubernetes|detection|networkgraph|protoconv|sensor|alerts|images|metrics|telemetry)/', +] +run = ["go-postgres", "sensor-integration-tests"] + +# Central domains that talk to Sensor (run both) +[[rules]] +name = "central-sensor-communication" +when = "any-file-matches" +paths = [ + '^central/(cluster|deployment|image|baseimage|node|pod|namespace|secret|serviceaccount|virtualmachine)/', + '^central/(alert|detection|policy|policycat)/', + '^central/(networkbaseline|networkgraph|networkpolicies)/', + '^central/(complianceoperator|processindicator)/', + '^central/(imageintegration|delegatedregistryconfig|enrichment|reprocessor)/', + '^central/(sensor|sensorupgrade|clusterinit|securedclustercertgen)/', + '^central/(metrics|telemetry|debug|scrape|vulnmgmt|hash)/', +] +run = ["go-postgres", "sensor-integration-tests"] + +# Sensor domains that talk to Central (run both) +[[rules]] +name = "sensor-central-communication" +when = "any-file-matches" +paths = [ + '^sensor/common/(admissioncontroller|centralclient|centralproxy|compliance|config|detector|enforcer|networkflow|sensor|image|scannerclient|virtualmachine)/', + '^sensor/kubernetes/(eventpipeline|listener|complianceoperator|networkpolicies|telemetry|upgrade|clusterhealth|clustermetrics|clusterstatus|certrefresh)/', +] +run = ["go-postgres", "sensor-integration-tests"] + +# Tier 10: E2E Test Selective Execution + +[[rules]] +name = "skip-e2e-byodb" +when = "every-file-matches" +paths = ['^ui/', '^roxctl/', '^operator/', '^sensor/', '^docs/', '^scripts/ci/', '^\.github/', '\.md$'] +skip = ["e2e-byodb-tests"] + +[[rules]] +name = "skip-e2e-db-backup-restore" +when = "every-file-matches" +paths = ['^ui/', '^roxctl/', '^operator/', '^sensor/', '^scanner/', '^docs/', '\.md$'] +skip = ["e2e-db-backup-restore-tests"] + +[[rules]] +name = "skip-e2e-nongroovy" +when = "every-file-matches" +paths = ['^ui/', '^docs/', '\.md$', '^operator/'] +skip = ["e2e-nongroovy-tests", "gke-nongroovy-e2e-tests"] + +[[rules]] +name = "skip-e2e-upgrade-all" +when = "every-file-matches" +paths = ['^ui/', '^docs/', '\.md$'] +skip = ["e2e-gke-upgrade-tests"] + +# Tier 11: Image Wait (always run) +[[rules]] +name = "image-wait" +when = "always" +run = ["wait-for-images"] + +# Tier 12: Remaining (default fallback) +[[rules]] +name = "remaining" +when = "remaining" +default = ["*"] + +[requires] +wait-for-images = ["should-dispatch"] +e2e-byodb-tests = ["wait-for-images"] +e2e-db-backup-restore-tests = ["wait-for-images"] +e2e-gke-upgrade-tests = ["wait-for-images"] +e2e-nongroovy-tests = ["wait-for-images"] +e2e-qa-tests-gke = ["wait-for-images"] +e2e-qa-tests-ocp-4-22 = ["wait-for-images"] +e2e-qa-tests-gke-konflux = ["wait-for-images"] From 56025a4e66f7497820512c2d4b16bb749183d4dd Mon Sep 17 00:00:00 2001 From: Piotr Rygielski <114479+vikin91@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:53:39 +0200 Subject: [PATCH 2/5] ROX-37371: Wire the Go CI selector into GHA and Prow Build before gating so the prototype can expose toolchain and bootstrap constraints. Keep selection label-gated, required checks successful on skips, and individual jobs running when the selector cannot be built. Move inline GHA e2e dispatch into the binary and preserve fork rejection. AI-Assisted: Code partially generated by Codex. --- .github/actions/ci-select/action.yaml | 12 + .../workflows/check-crd-compatibility.yaml | 24 ++ .github/workflows/ci-decision.yaml | 46 ++++ .github/workflows/e2e-dispatch.yaml | 127 +++------- .../scanner-db-integration-tests.yaml | 24 ++ .github/workflows/style.yaml | 151 +++++++++++- .github/workflows/unit-tests.yaml | 233 ++++++++++++++++-- .openshift-ci/dispatch.sh | 20 +- ci/select/gha.go | 3 +- 9 files changed, 508 insertions(+), 132 deletions(-) create mode 100644 .github/actions/ci-select/action.yaml create mode 100644 .github/workflows/ci-decision.yaml diff --git a/.github/actions/ci-select/action.yaml b/.github/actions/ci-select/action.yaml new file mode 100644 index 0000000000000..efa8768f68a4a --- /dev/null +++ b/.github/actions/ci-select/action.yaml @@ -0,0 +1,12 @@ +name: Build CI selector +description: Build the Go CI selection prototype before gated setup steps +runs: + using: composite + steps: + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # ratchet:actions/setup-go@v6 + with: + go-version-file: go.mod + cache: false + - name: Build CI selector + shell: bash + run: ci/select/build.sh diff --git a/.github/workflows/check-crd-compatibility.yaml b/.github/workflows/check-crd-compatibility.yaml index d5d2ba0ffd62b..1c7add7c825a3 100644 --- a/.github/workflows/check-crd-compatibility.yaml +++ b/.github/workflows/check-crd-compatibility.yaml @@ -35,11 +35,31 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh check-crd-compatibility + - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Checkout crd-schema-checker + if: steps.ci-decision.outputs.action != 'skip' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 with: repository: openshift/crd-schema-checker @@ -47,10 +67,12 @@ jobs: ref: c313b6407231f6d1aabfb602f0823708f8c6b9fb # 2025-09-05 - name: Build crd-schema-checker + if: steps.ci-decision.outputs.action != 'skip' run: | make -C .crd-schema-checker - name: Get previous released version + if: steps.ci-decision.outputs.action != 'skip' id: get_previous_released_version run: | make -C operator bundle-post-process @@ -58,6 +80,7 @@ jobs: echo "released_version=$released_version" >> "$GITHUB_OUTPUT" - name: Checkout previous released version + if: steps.ci-decision.outputs.action != 'skip' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 with: fetch-depth: 0 @@ -65,6 +88,7 @@ jobs: path: .old-stackrox - name: Run crd-schema-checker for platform operator + if: steps.ci-decision.outputs.action != 'skip' run: | set -e old_path=.old-stackrox/operator/config/crd/bases diff --git a/.github/workflows/ci-decision.yaml b/.github/workflows/ci-decision.yaml new file mode 100644 index 0000000000000..43dde9956cf73 --- /dev/null +++ b/.github/workflows/ci-decision.yaml @@ -0,0 +1,46 @@ +name: CI decision + +# Prints the decision for a pull request. It does not start or stop other +# jobs. The label ci-dispatcher-enforce is read by the jobs that follow +# the decision. + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + +permissions: + contents: read + +jobs: + decision: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - uses: ./.github/actions/ci-select + + - name: Test CI selector + run: go test -race ./ci/select + + - name: Validate CI rules + run: ci/select/bin/ci-select validate + + - name: Print the CI decision + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh diff --git a/.github/workflows/e2e-dispatch.yaml b/.github/workflows/e2e-dispatch.yaml index 4335fefeb05cb..0fc8897c69321 100644 --- a/.github/workflows/e2e-dispatch.yaml +++ b/.github/workflows/e2e-dispatch.yaml @@ -13,6 +13,8 @@ on: - reopened - synchronize - ready_for_review + - labeled + - unlabeled permissions: contents: read @@ -30,108 +32,45 @@ jobs: # This allows to skip running E2E tests on draft PRs without corresponding labels or on untrusted forks. should-dispatch: needs: [ detect-changes ] - runs-on: ubuntu-slim + runs-on: ubuntu-24.04 outputs: wait-for-gha-images: ${{ steps.determine.outputs.wait-for-gha-images }} wait-for-konflux-images: ${{ steps.determine.outputs.wait-for-konflux-images }} jobs: ${{ steps.determine.outputs.jobs }} steps: - - name: Determine whether should dispatch workflows + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: ./.github/actions/ci-select + + - name: Print the CI decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh + + - name: Determine whether to dispatch workflows id: determine - shell: python env: GH_TOKEN: ${{ github.token }} - PYTHONUNBUFFERED: "1" - input__event_name: ${{ github.event_name }} - input__is_fork: ${{ github.event.pull_request.head.repo.fork }} - input__is_draft: ${{ github.event.pull_request.draft }} - input__ui_only_change: ${{ needs.detect-changes.outputs.ui_only }} - input__repo: ${{ github.repository }} - input__pr_number: ${{ github.event.pull_request.number }} - run: | - import json - import os - import subprocess - from types import SimpleNamespace - from dataclasses import dataclass - - @dataclass - class Job: - name: str - require_pr_label: bool = False - require_konflux_build: bool = False - - def should_run(self, input): - if input.is_fork == 'true': - return False - - if input.event_name != 'pull_request': - return True - elif self.name in input.pr_labels: - return True - - # We are in a PR context, job is not explicitly listed among the PR labels. - - # TODO(ROX-21073): re-enable Konflux for all PR branches - if self.require_konflux_build and 'konflux-build' not in input.pr_labels: - return False - - if input.ui_only_change == 'true': - # For now, all the workflows that we dispatch should be skipped for PRs that only change UI. - # As we add more E2E test workflows, we may want to make this more flexible. - return False - if self.require_pr_label: - return False - if input.is_draft != 'true': - return True - - return False - - - input = SimpleNamespace(**{ - key.removeprefix('input__'): val - for (key, val) in os.environ.items() - if key.startswith('input__')}) - - if input.event_name != 'pull_request': - input.pr_labels = [] - else: - p = subprocess.run( - ['gh', 'pr', 'view', input.pr_number, '--repo', input.repo, '--json', 'labels', '--jq', '.labels[].name'], - check=True, encoding='utf-8', stdout=subprocess.PIPE, stdin=subprocess.DEVNULL) - input.pr_labels = p.stdout.strip().splitlines() - - for k, v in vars(input).items(): - print(f'{k} = {v}') - - jobs_registry = [ - Job(name='e2e-byodb-tests', require_pr_label=True), - Job(name='e2e-db-backup-restore-tests'), - Job(name='e2e-gke-upgrade-tests'), - Job(name='e2e-nongroovy-tests-gke'), - Job(name='e2e-nongroovy-tests-gke-konflux', require_konflux_build=True), - Job(name='e2e-qa-tests-gke', require_pr_label=True), - Job(name='e2e-qa-tests-gke-konflux', require_konflux_build=True), - Job(name='e2e-qa-tests-ocp-fips-konflux', require_konflux_build=True), - ] - - with open(os.environ['GITHUB_OUTPUT'], mode='at', encoding='utf-8') as file: - def write(job, val): - line = f'{job}={val}' - print(line) - file.write(line + '\n') - - jobs = {job.name: job.should_run(input) for job in jobs_registry} - - write('jobs', json.dumps(jobs)) - - # Find out when we can complete the workflow early by not even waiting for GHA-built images. - wait_for_gha_images = input.event_name != 'pull_request' or input.is_draft != 'true' or any(jobs.values()) - write('wait-for-gha-images', str(wait_for_gha_images).lower()) - - # Waiting for konflux-built images in PR request context requires the 'konflux-build' PR label. - wait_for_konflux_images = input.event_name != 'pull_request' or ('konflux-build' in input.pr_labels and any(jobs.values())) - write('wait-for-konflux-images', str(wait_for_konflux_images).lower()) + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + CI_IS_FORK: ${{ github.event.pull_request.head.repo.fork }} + CI_IS_DRAFT: ${{ github.event.pull_request.draft }} + CI_UI_ONLY: ${{ needs.detect-changes.outputs.ui_only }} + run: ci/select/bin/ci-select gha-dispatch-ci wait-for-images: needs: [ should-dispatch ] diff --git a/.github/workflows/scanner-db-integration-tests.yaml b/.github/workflows/scanner-db-integration-tests.yaml index effbe1190271e..351a2e09e58e6 100644 --- a/.github/workflows/scanner-db-integration-tests.yaml +++ b/.github/workflows/scanner-db-integration-tests.yaml @@ -28,21 +28,45 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 with: fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh db-integration-tests - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Run Postgres + if: steps.ci-decision.outputs.action != 'skip' run: | su postgres -c 'initdb -D /tmp/data' su postgres -c 'pg_ctl -D /tmp/data start' - name: Cache Go dependencies + if: steps.ci-decision.outputs.action != 'skip' uses: ./.github/actions/cache-go-dependencies - name: Is Postgres ready + if: steps.ci-decision.outputs.action != 'skip' run: pg_isready -h 127.0.0.1 - name: DB Integration tests + if: steps.ci-decision.outputs.action != 'skip' run: make -C scanner db-integration-test diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml index 3989115a09bab..5ab347653809a 100644 --- a/.github/workflows/style.yaml +++ b/.github/workflows/style.yaml @@ -11,6 +11,8 @@ on: - opened - reopened - synchronize + - labeled + - unlabeled env: ROX_PRODUCT_BRANDING: RHACS_BRANDING @@ -35,20 +37,42 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh check-generated-files + - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Create artifacts dir + if: steps.ci-decision.outputs.action != 'skip' run: mkdir -p "$ARTIFACT_DIR" # No GOCACHE here — this job must validate go.mod/go.sum against # real source files, not a cached module index that may be stale. - name: Download scanner module for proto generation + if: steps.ci-decision.outputs.action != 'skip' run: go mod download github.com/stackrox/scanner - name: Check Generated + if: steps.ci-decision.outputs.action != 'skip' run: scripts/ci/jobs/check-generated.sh misc-checks: @@ -96,6 +120,8 @@ jobs: - name: Check dependabot gomod configurations match go.mod files run: if [ -s scripts/ci/jobs/check-dependabot-gomod.sh ]; then scripts/ci/jobs/check-dependabot-gomod.sh; fi + # The label ci-dispatcher-enforce makes this job follow the decision. + # Without the label the job runs as it does today. style-check: runs-on: ubuntu-24.04 steps: @@ -105,54 +131,83 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh style-check + - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: free-disk-space: '30' gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # ratchet:actions/setup-node@v6 + if: steps.ci-decision.outputs.action != 'skip' with: node-version-file: ui/package.json - name: Install xmlstarlet for shellcheck output to junit + if: steps.ci-decision.outputs.action != 'skip' run: | sudo apt-get update -qq && sudo apt-get install -y -qq xmlstarlet - name: Install pycodestyle + if: steps.ci-decision.outputs.action != 'skip' run: | python3 -m pip install -r .openshift-ci/dev-requirements.txt - name: Verify tools + if: steps.ci-decision.outputs.action != 'skip' run: | yq --version xmlstarlet --version pycodestyle --version - name: Cache Go dependencies + if: steps.ci-decision.outputs.action != 'skip' uses: ./.github/actions/cache-go-dependencies - name: Download scanner module for proto generation + if: steps.ci-decision.outputs.action != 'skip' run: go mod download github.com/stackrox/scanner - name: Cache UI dependencies id: cache-ui + if: steps.ci-decision.outputs.action != 'skip' uses: ./.github/actions/cache-ui-dependencies - name: Cache QA Test dependencies + if: steps.ci-decision.outputs.action != 'skip' uses: ./.github/actions/cache-gradle-dependencies - name: Fetch UI deps - if: steps.cache-ui.outputs.cache-hit != 'true' + if: steps.ci-decision.outputs.action != 'skip' && steps.cache-ui.outputs.cache-hit != 'true' run: make -C ui deps - name: Skip UI deps on cache hit - if: steps.cache-ui.outputs.cache-hit == 'true' + if: steps.ci-decision.outputs.action != 'skip' && steps.cache-ui.outputs.cache-hit == 'true' run: touch ui/deps - name: Cache ESLint results + if: steps.ci-decision.outputs.action != 'skip' uses: ./.github/actions/cache-eslint - name: make style-slim + if: steps.ci-decision.outputs.action != 'skip' run: make style-slim golangci-lint: @@ -163,16 +218,39 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} + ref: ${{ github.event.pull_request.head.sha }} + + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh golangci-lint - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Check Cache golangci-lint + if: steps.ci-decision.outputs.action != 'skip' run: make golangci-lint-cache-status - name: Cache golangci-lint - if: github.event_name == 'push' && github.ref_name == github.event.repository.default_branch + if: (github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) && steps.ci-decision.outputs.action != 'skip' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # ratchet:actions/cache@v6 with: path: ${{ env.HOME }}/.cache/golangci-lint @@ -181,7 +259,7 @@ jobs: go-lint-v2- - name: Restore golangci-lint analysis cache - if: ${{ !(github.event_name == 'push' && github.ref_name == github.event.repository.default_branch) }} + if: (!(github.event_name == 'push' && github.ref_name == github.event.repository.default_branch)) && steps.ci-decision.outputs.action != 'skip' uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # ratchet:actions/cache/restore@v6 with: path: ${{ env.HOME }}/.cache/golangci-lint @@ -190,16 +268,20 @@ jobs: go-lint-v2- - name: Check cache golangci-lint + if: steps.ci-decision.outputs.action != 'skip' run: make golangci-lint-cache-status - name: Check Go version is in sync in .golangci.yml and go.mod + if: steps.ci-decision.outputs.action != 'skip' run: | [ "$(awk '/^go /{print $2}' go.mod | cut -d. -f 1-2)" = "$(yq e .run.go .golangci.yml)" ] - name: make golangci-lint + if: steps.ci-decision.outputs.action != 'skip' run: make golangci-lint - name: Check Cache golangci-lint + if: steps.ci-decision.outputs.action != 'skip' run: make golangci-lint-cache-status check-dependent-images-exist: @@ -256,10 +338,34 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} + ref: ${{ github.event.pull_request.head.sha }} + + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh github-actions-lint + - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Check workflow files + if: steps.ci-decision.outputs.action != 'skip' run: make github-actions-lint github-actions-shellcheck: @@ -289,19 +395,56 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh openshift-ci-lint + - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Install Python linters + if: steps.ci-decision.outputs.action != 'skip' run: python3 -m pip install -r .openshift-ci/dev-requirements.txt - name: pycodestyle + if: steps.ci-decision.outputs.action != 'skip' run: make -C .openshift-ci style - name: Pylint + if: steps.ci-decision.outputs.action != 'skip' run: make -C .openshift-ci lint + validate-test-domains: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Validate test-domains.toml + run: python3 ci/select/validate_rules.py --rules ci/test-domains.toml --defaults ci/decision-defaults + + - name: Run resolver unit tests + run: python3 -m unittest discover -s ci/select -p 'test_*.py' + slack-on-style-failure: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 48ea27c9d0873..5e209daf0db26 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -11,6 +11,8 @@ on: - opened - reopened - synchronize + - labeled + - unlabeled concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -33,6 +35,9 @@ jobs: ^.github/workflows/unit-tests.yaml$ ^.github/actions/job-preamble/ + # The label ci-dispatcher-enforce makes this job follow the decision. + # Without the label the job runs as it does today. A skip still starts + # the job and exits successfully, so the required check can merge. go: needs: detect-changes strategy: @@ -47,56 +52,85 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + # A pull request needs both commits so the decision can see the diff. + # The 0 stays quoted: a bare 0 is treated as false and the fetch becomes shallow. + fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} + + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh go - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} free-disk-space: 30 - name: Cache Go dependencies + if: steps.ci-decision.outputs.action != 'skip' uses: ./.github/actions/cache-go-dependencies with: key-suffix: ${{ matrix.gotags }} - name: Go Unit Tests + if: steps.ci-decision.outputs.action != 'skip' run: ${{ matrix.gotags }} make go-unit-tests - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # ratchet:codecov/codecov-action@v7.1.1 + if: steps.ci-decision.outputs.action != 'skip' with: token: ${{ secrets.CODECOV_TOKEN }} flags: go-unit-tests - name: Generate junit report - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' run: make generate-junit-reports - name: Publish Test Report uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # ratchet:test-summary/action@v2 - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' with: paths: 'junit-reports/report.xml' - name: Go Integration Unit Tests + if: steps.ci-decision.outputs.action != 'skip' run: ${{ matrix.gotags }} make integration-unit-tests - name: Go Operator Integration Tests + if: steps.ci-decision.outputs.action != 'skip' run: ${{ matrix.gotags }} make -C operator/ test-integration - name: Go Operator Helm Tests + if: steps.ci-decision.outputs.action != 'skip' run: ${{ matrix.gotags }} make -C operator/ test-helm - name: Generate junit report - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' run: make generate-junit-reports - name: Publish Test Report uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # ratchet:test-summary/action@v2 - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' with: paths: 'junit-reports/report.xml' - name: Report test failures to Jira - if: (!cancelled()) + if: (!cancelled()) && steps.ci-decision.outputs.action != 'skip' id: junit2jira uses: ./.github/actions/junit2jira with: @@ -123,11 +157,32 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} + ref: ${{ github.event.pull_request.head.sha }} + + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh go-postgres # Runner's postgres is initialized with C.UTF-8 locale (GHA default). # Tests (e.g. central/splunk) require byte-order collation. - name: Start Postgres (pre-installed) - if: matrix.pg == 'default' + if: (matrix.pg == 'default') && steps.ci-decision.outputs.action != 'skip' run: | # Run on tmpfs to avoid disk I/O overhead in tests. PG_VER=$(pg_lsclusters -h | awk '{print $1}') @@ -140,7 +195,7 @@ jobs: # Image pinned in .github/test-images/postgres.dockerfile (Dependabot auto-bumps). - name: Start Postgres (${{ matrix.pg }}) - if: matrix.pg != 'default' + if: (matrix.pg != 'default') && steps.ci-decision.outputs.action != 'skip' run: | docker build -t postgres-test -f .github/test-images/postgres.dockerfile . docker run --rm -d --name postgres --network=host \ @@ -151,15 +206,18 @@ jobs: postgres-test - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Cache Go dependencies + if: steps.ci-decision.outputs.action != 'skip' uses: ./.github/actions/cache-go-dependencies with: key-suffix: ${{ matrix.gotags }} - name: Is Postgres ready + if: steps.ci-decision.outputs.action != 'skip' run: | if [ "${{ matrix.pg }}" = "default" ]; then timeout 60 bash -c 'until pg_isready; do sleep 1; done' \ @@ -170,25 +228,27 @@ jobs: fi - name: Go Unit Tests + if: steps.ci-decision.outputs.action != 'skip' run: ${{ matrix.gotags }} make go-postgres-unit-tests - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # ratchet:codecov/codecov-action@v7.1.1 + if: steps.ci-decision.outputs.action != 'skip' with: token: ${{ secrets.CODECOV_TOKEN }} flags: go-unit-tests - name: Generate junit report - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' run: make generate-junit-reports - name: Publish Test Report uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # ratchet:test-summary/action@v2 - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' with: paths: 'junit-reports/report.xml' - name: Report junit failures in jira - if: (!cancelled()) + if: (!cancelled()) && steps.ci-decision.outputs.action != 'skip' id: junit2jira uses: ./.github/actions/junit2jira with: @@ -269,39 +329,64 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} + ref: ${{ github.event.pull_request.head.sha }} + + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh ui - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Set up Node.js + if: steps.ci-decision.outputs.action != 'skip' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # ratchet:actions/setup-node@v6 with: node-version-file: ui/package.json - name: Cache UI dependencies + if: steps.ci-decision.outputs.action != 'skip' id: cache-ui uses: ./.github/actions/cache-ui-dependencies - name: Install UI dependencies - if: steps.cache-ui.outputs.cache-hit != 'true' + if: (steps.cache-ui.outputs.cache-hit != 'true') && steps.ci-decision.outputs.action != 'skip' run: make -C ui deps - name: Skip UI deps on cache hit - if: steps.cache-ui.outputs.cache-hit == 'true' + if: (steps.cache-ui.outputs.cache-hit == 'true') && steps.ci-decision.outputs.action != 'skip' run: touch ui/deps - name: UI Unit Tests + if: steps.ci-decision.outputs.action != 'skip' run: make ui-test - name: Publish Test Report uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # ratchet:test-summary/action@v2 - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' with: paths: 'ui/apps/platform/test-results/reports/*.xml' - name: Report junit failures in jira - if: (!cancelled()) + if: (!cancelled()) && steps.ci-decision.outputs.action != 'skip' id: junit2jira uses: ./.github/actions/junit2jira with: @@ -323,29 +408,54 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} + ref: ${{ github.event.pull_request.head.sha }} + + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh ui-component - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Set up Node.js + if: steps.ci-decision.outputs.action != 'skip' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # ratchet:actions/setup-node@v6 with: node-version-file: ui/package.json - name: Cache UI dependencies + if: steps.ci-decision.outputs.action != 'skip' id: cache-ui uses: ./.github/actions/cache-ui-dependencies - name: Install UI dependencies - if: steps.cache-ui.outputs.cache-hit != 'true' + if: (steps.cache-ui.outputs.cache-hit != 'true') && steps.ci-decision.outputs.action != 'skip' run: make -C ui deps - name: Skip UI deps on cache hit - if: steps.cache-ui.outputs.cache-hit == 'true' + if: (steps.cache-ui.outputs.cache-hit == 'true') && steps.ci-decision.outputs.action != 'skip' run: touch ui/deps - name: UI Component Tests + if: steps.ci-decision.outputs.action != 'skip' uses: cypress-io/github-action@dace029018fcdf86e0df89a31bc3cfa5b32570d8 # ratchet:cypress-io/github-action@v7 with: working-directory: ui/apps/platform @@ -354,19 +464,19 @@ jobs: - name: Upload Cypress test result artifacts uses: ./.github/actions/upload-artifact-with-retry - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' with: name: Cypress test results path: ui/apps/platform/cypress/test-results/ - name: Publish Test Report uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # ratchet:test-summary/action@v2 - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' with: paths: 'ui/apps/platform/cypress/test-results/reports/**/*.xml' - name: Report junit failures in jira - if: (!cancelled()) + if: (!cancelled()) && steps.ci-decision.outputs.action != 'skip' id: junit2jira uses: ./.github/actions/junit2jira with: @@ -391,28 +501,51 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 with: + fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} ref: ${{ github.event.pull_request.head.sha }} + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh local-roxctl-tests + - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Cache Go dependencies + if: steps.ci-decision.outputs.action != 'skip' uses: ./.github/actions/cache-go-dependencies - uses: ./.github/actions/handle-tagged-build + if: steps.ci-decision.outputs.action != 'skip' - name: Local roxctl tests + if: steps.ci-decision.outputs.action != 'skip' run: ./scripts/ci/jobs/local-roxctl-tests.sh - name: Publish Test Report uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # ratchet:test-summary/action@v2 - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' with: paths: 'roxctl-test-output/*.xml' - name: Report junit failures in jira - if: (!cancelled()) + if: (!cancelled()) && steps.ci-decision.outputs.action != 'skip' id: junit2jira uses: ./.github/actions/junit2jira with: @@ -434,23 +567,44 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 with: + fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} ref: ${{ github.event.pull_request.head.sha }} + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh shell-unit-tests + - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Shell Unit Tests + if: steps.ci-decision.outputs.action != 'skip' run: make shell-unit-tests - name: Publish Test Report uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # ratchet:test-summary/action@v2 - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' with: paths: 'shell-test-output/*.xml' - name: Report junit failures in jira - if: (!cancelled()) + if: (!cancelled()) && steps.ci-decision.outputs.action != 'skip' id: junit2jira uses: ./.github/actions/junit2jira with: @@ -491,15 +645,39 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }} + ref: ${{ github.event.pull_request.head.sha }} + + - name: Build CI selector + if: github.event_name == 'pull_request' + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh sensor-integration-tests - uses: ./.github/actions/job-preamble + if: steps.ci-decision.outputs.action != 'skip' with: gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT_STACKROX_CI }} - name: Cache Go dependencies + if: steps.ci-decision.outputs.action != 'skip' uses: ./.github/actions/cache-go-dependencies - name: Login to Quay.io + if: steps.ci-decision.outputs.action != 'skip' uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # ratchet:docker/login-action@v4 with: registry: quay.io @@ -507,11 +685,13 @@ jobs: password: ${{ secrets.QUAY_RHACS_ENG_RO_PASSWORD }} - name: Create k8s Kind Cluster + if: steps.ci-decision.outputs.action != 'skip' uses: helm/kind-action@06c1ae10762d3b9c1644e7fe69596ae519e015a2 # ratchet:helm/kind-action@v1.15.0 with: kubeconfig: "${{ env.KUBECONFIG }}" - name: Load test images into Kind + if: steps.ci-decision.outputs.action != 'skip' run: | source scripts/lib.sh # Read images from prefetch list (single source of truth) @@ -530,20 +710,21 @@ jobs: done < sensor/tests/images-to-prefetch.txt - name: Run sensor integration tests + if: steps.ci-decision.outputs.action != 'skip' run: make sensor-integration-test - name: Generate junit report - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' run: make generate-junit-reports - name: Publish Test Report uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # ratchet:test-summary/action@v2 - if: always() + if: always() && steps.ci-decision.outputs.action != 'skip' with: paths: 'junit-reports/report.xml' - name: Report junit failures in jira - if: (!cancelled()) + if: (!cancelled()) && steps.ci-decision.outputs.action != 'skip' id: junit2jira uses: ./.github/actions/junit2jira with: diff --git a/.openshift-ci/dispatch.sh b/.openshift-ci/dispatch.sh index bd8e4ce1a1a27..89e2cc9ca1278 100755 --- a/.openshift-ci/dispatch.sh +++ b/.openshift-ci/dispatch.sh @@ -19,15 +19,21 @@ fi ci_job="$1" shift -# Skip non-UI jobs early when the PR only touches files under ui/. -case "$ci_job" in - *nongroovy*|*upgrade*) - if changes_limited_to "ui/"; then - info "Skipping $ci_job: all changes are under ui/" +# On a pull request, skip before credentials and cluster setup when the +# decision leaves this job off the list. The label ci-dispatcher-enforce +# is what makes that skip real. Without it the job runs as it does today. +if is_in_PR_context; then + if "$ROOT/ci/select/build.sh"; then + # shellcheck source=../ci/select/prow-gate.sh + source "$ROOT/ci/select/prow-gate.sh" + if ! ci_decision_allows "$ci_job"; then + info "CI decision skips ${ci_job}" exit 0 fi - ;; -esac + else + info "CI selector build failed; ${ci_job} runs" + fi +fi if [[ -f "${SHARED_DIR:-}/shared_env" ]]; then # shellcheck disable=SC1091 diff --git a/ci/select/gha.go b/ci/select/gha.go index b69da14d8e718..2b1f6c52d01a5 100644 --- a/ci/select/gha.go +++ b/ci/select/gha.go @@ -137,7 +137,8 @@ var dispatchJobs = []dispatchJob{ {name: "e2e-byodb-tests", requireLabel: true}, {name: "e2e-db-backup-restore-tests"}, {name: "e2e-gke-upgrade-tests"}, - {name: "e2e-nongroovy-tests"}, + {name: "e2e-nongroovy-tests-gke"}, + {name: "e2e-nongroovy-tests-gke-konflux", requireKonflux: true}, {name: "e2e-qa-tests-gke", requireLabel: true}, {name: "e2e-qa-tests-gke-konflux", requireKonflux: true}, {name: "e2e-qa-tests-ocp-fips-konflux", requireKonflux: true}, From a44e7cbad1a3853d1aa63f169a5f685f52715177 Mon Sep 17 00:00:00 2001 From: Piotr Rygielski <114479+vikin91@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:20:51 +0200 Subject: [PATCH 3/5] ROX-37371: Fix CI selector checks and dispatch fallback Gate all CRD validation steps and run Go validation in style CI. Satisfy repository vet checks and use event labels with enforcement disabled when live label lookup fails. Document binary build and runtime recovery as an open gap. AI-Assisted: Code partially generated by Codex. --- .../workflows/check-crd-compatibility.yaml | 3 + .github/workflows/e2e-dispatch.yaml | 4 ++ .github/workflows/style.yaml | 7 ++- ci/select/README.md | 21 ++++++- ci/select/cli.go | 6 +- ci/select/config.go | 10 ++-- ci/select/decision.go | 6 +- ci/select/gha.go | 10 ++-- ci/select/gha_test.go | 55 +++++++++++++++++++ ci/select/github.go | 6 +- ci/select/prow.go | 9 ++- ci/select/resolver.go | 2 +- .../analyzers/validateimports/analyzer.go | 1 + 13 files changed, 115 insertions(+), 25 deletions(-) diff --git a/.github/workflows/check-crd-compatibility.yaml b/.github/workflows/check-crd-compatibility.yaml index 1c7add7c825a3..67472af8a9bb9 100644 --- a/.github/workflows/check-crd-compatibility.yaml +++ b/.github/workflows/check-crd-compatibility.yaml @@ -101,6 +101,7 @@ jobs: .crd-schema-checker/crd-schema-checker check-manifests $args --existing-crd-filename=$old_path/$central_file --new-crd-filename=$new_path/$central_file - name: Run crd-schema-checker for config operator + if: steps.ci-decision.outputs.action != 'skip' run: | set -e old_path=.old-stackrox/config-controller/config/crd/bases @@ -111,6 +112,7 @@ jobs: .crd-schema-checker/crd-schema-checker check-manifests $args --existing-crd-filename=$old_path/$policy_file --new-crd-filename=$new_path/$policy_file - name: Run crdify for platform operator + if: steps.ci-decision.outputs.action != 'skip' run: | set -euo pipefail old_path="$PWD/.old-stackrox/operator/config/crd/bases" @@ -120,6 +122,7 @@ jobs: $crdify "${old_path}/platform.stackrox.io_centrals.yaml" "${new_path}/platform.stackrox.io_centrals.yaml" - name: Run crdify for config operator + if: steps.ci-decision.outputs.action != 'skip' run: | set -euo pipefail old_path="$PWD/.old-stackrox/config-controller/config/crd/bases" diff --git a/.github/workflows/e2e-dispatch.yaml b/.github/workflows/e2e-dispatch.yaml index 0fc8897c69321..9c10a0a451b51 100644 --- a/.github/workflows/e2e-dispatch.yaml +++ b/.github/workflows/e2e-dispatch.yaml @@ -44,6 +44,9 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }} + # TODO(ROX-37371): Decide binary distribution and recovery when the selector + # cannot compile or run. A prebuilt binary removes compilation failures, + # but dispatch still needs a fallback independent of the binary. - uses: ./.github/actions/ci-select - name: Print the CI decision @@ -70,6 +73,7 @@ jobs: CI_IS_FORK: ${{ github.event.pull_request.head.repo.fork }} CI_IS_DRAFT: ${{ github.event.pull_request.draft }} CI_UI_ONLY: ${{ needs.detect-changes.outputs.ui_only }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} run: ci/select/bin/ci-select gha-dispatch-ci wait-for-images: diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml index 5ab347653809a..5d35b70794a19 100644 --- a/.github/workflows/style.yaml +++ b/.github/workflows/style.yaml @@ -439,11 +439,14 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} + - name: Build CI selector + uses: ./.github/actions/ci-select + - name: Validate test-domains.toml - run: python3 ci/select/validate_rules.py --rules ci/test-domains.toml --defaults ci/decision-defaults + run: ci/select/bin/ci-select validate - name: Run resolver unit tests - run: python3 -m unittest discover -s ci/select -p 'test_*.py' + run: go test -race ./ci/select slack-on-style-failure: env: diff --git a/ci/select/README.md b/ci/select/README.md index 0c1e22c3ff853..5045fa38d6d6c 100644 --- a/ci/select/README.md +++ b/ci/select/README.md @@ -131,8 +131,11 @@ job map plus `wait-for-gha-images` and `wait-for-konflux-images` outputs. It reads `GITHUB_EVENT_NAME`, the metadata variables listed earlier, and `CI_IS_FORK`, `CI_IS_DRAFT`, and `CI_UI_ONLY`. Fork rejection is preserved. With enforcement disabled, non-PR events, or unusable defaults, it follows -the existing dispatcher rules. Failure to retrieve live labels fails this -dispatch step, as the original dispatcher did. +the existing dispatcher rules. If live label lookup fails, it logs a warning, +uses `PR_LABELS` from the GitHub event, and disables enforcement for that run. +Event labels may be stale; explicit job labels and the existing fork, draft, +UI-only, and Konflux rules still apply. With no event labels, it applies those +rules with an empty label list. `prow-gate.sh` defines `ci_decision_allows`. It pipes the existing CI library's `get_pr_details` JSON into `ci-select prow-ci JOB`. Go reads the @@ -155,6 +158,20 @@ failure and proceed with their normal work. The decision and e2e dispatch workflows require a successful build. Prow builds once at dispatch entry; build failure bypasses selection and runs the job. +### Open gap: build and runtime failure recovery + +GHA's `should-dispatch` has no fallback if the selector cannot compile, is +missing, or fails to run. These failures block E2E dispatch. Defaults and the +label-lookup fallback work only while the binary is running. + +The distribution model remains an open decision: build the selector in each +job, or provide a prebuilt binary. A prebuilt binary avoids compilation +failures in the dispatch job but can still fail to start or execute. Either +model needs a backup dispatch path independent of the selector binary, +including when both compilation and execution are unavailable. The fallback +policy must account for fork rejection, draft and label restrictions, and +image-wait outputs. This prototype leaves that recovery path unimplemented. + These integration costs still need validation in real CI: - Cold jobs may download a Go toolchain and module dependencies before they diff --git a/ci/select/cli.go b/ci/select/cli.go index cc82070fa7a49..237535852be2c 100644 --- a/ci/select/cli.go +++ b/ci/select/cli.go @@ -21,10 +21,10 @@ func runLocal(command, mode string, o options, output, diagnostic io.Writer) err return fmt.Errorf("unknown %s mode %q", command, mode) } if mode == "gate" && o.job == "" { - return fmt.Errorf("gate needs --job") + return errors.New("gate needs --job") } if o.unknown == (o.files != "") { - return fmt.Errorf("pass either --files-file or --files-unknown") + return errors.New("pass either --files-file or --files-unknown") } files := []string{} if !o.unknown { @@ -48,7 +48,7 @@ func runLocal(command, mode string, o options, output, diagnostic io.Writer) err var err error if command == "resolve" { if o.unknown { - return fmt.Errorf("changed files are unknown") + return errors.New("changed files are unknown") } m, loadErr := loadMapping(o.rules) if loadErr != nil { diff --git a/ci/select/config.go b/ci/select/config.go index 8861ef12fd682..f90753ab9cdfa 100644 --- a/ci/select/config.go +++ b/ci/select/config.go @@ -35,7 +35,7 @@ func loadMapping(path string) (mapping, error) { return result, errors.Wrap(err, "read TOML rules") } if meta.IsDefined("requires") && meta.Type("requires") != "Hash" { - return result, fmt.Errorf("requires must be a table") + return result, errors.New("requires must be a table") } return result, result.validate() } @@ -45,7 +45,7 @@ func (m *mapping) validate() error { return fmt.Errorf("unsupported rules version: %d", m.Version) } if len(m.Jobs) == 0 { - return fmt.Errorf("jobs must list at least one target") + return errors.New("jobs must list at least one target") } jobs := map[string]bool{} for _, job := range m.Jobs { @@ -58,7 +58,7 @@ func (m *mapping) validate() error { jobs[job] = true } if len(m.Rules) == 0 { - return fmt.Errorf("rules must be a non-empty list") + return errors.New("rules must be a non-empty list") } names := map[string]bool{} for index := range m.Rules { @@ -76,10 +76,10 @@ func (m *mapping) validate() error { } last := m.Rules[len(m.Rules)-1] if last.When != "remaining" { - return fmt.Errorf("the last rule must decide every target no earlier rule decided") + return errors.New("the last rule must decide every target no earlier rule decided") } if !slices.Contains(last.Run, "*") && !slices.Contains(last.Skip, "*") && !slices.Contains(last.Default, "*") { - return fmt.Errorf("the last rule must run, skip, or take the default for every remaining target") + return errors.New("the last rule must run, skip, or take the default for every remaining target") } for job, required := range m.Requires { if !jobs[job] { diff --git a/ci/select/decision.go b/ci/select/decision.go index 60c541f63ec1f..50bd7cc325565 100644 --- a/ci/select/decision.go +++ b/ci/select/decision.go @@ -3,6 +3,8 @@ package main import ( "fmt" "strings" + + "github.com/pkg/errors" ) type defaults struct { @@ -47,7 +49,7 @@ func parseDefaults(text string) (defaults, error) { result.actions[job] = action } if len(result.jobs) == 0 { - return result, fmt.Errorf("decision-defaults lists no targets") + return result, errors.New("decision-defaults lists no targets") } return result, nil } @@ -55,7 +57,7 @@ func parseDefaults(text string) (defaults, error) { func parseDecision(text string) (map[string]bool, error) { jobs := map[string]bool{} if strings.TrimSpace(text) == "" { - return jobs, fmt.Errorf("empty decision") + return jobs, errors.New("empty decision") } for index, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) diff --git a/ci/select/gha.go b/ci/select/gha.go index 2b1f6c52d01a5..e0bb868796c7e 100644 --- a/ci/select/gha.go +++ b/ci/select/gha.go @@ -60,7 +60,7 @@ func envOr(name, fallback string) string { func ghaCI(ctx context.Context, args []string, output, diagnostic io.Writer) error { if len(args) > 1 { - return fmt.Errorf("gha-ci takes at most one job name") + return errors.New("gha-ci takes at most one job name") } resolved, d, err := ghaInputs(ctx) if err != nil { @@ -198,10 +198,12 @@ func ghaDispatchCI(ctx context.Context, output, diagnostic io.Writer) error { meta := envMetadata() labels, err := newGitHubClient().labels(ctx, meta.repo, meta.pr) if err != nil { - return fmt.Errorf("read dispatch labels: %w", err) + input.labels = readList(strings.ReplaceAll(os.Getenv("PR_LABELS"), ",", "\n")) + fmt.Fprintf(diagnostic, "Cannot read live dispatch labels (%v); using event labels and existing dispatch rules with enforcement disabled\n", err) + } else { + input.labels = labels + input.enforce = slices.Contains(labels, enforceLabel) } - input.labels = labels - input.enforce = slices.Contains(labels, enforceLabel) } var resolved *result var d defaults diff --git a/ci/select/gha_test.go b/ci/select/gha_test.go index 9622e36a7a74b..ee74ae26451d2 100644 --- a/ci/select/gha_test.go +++ b/ci/select/gha_test.go @@ -105,3 +105,58 @@ func TestGHADispatchCI(t *testing.T) { assert.Contains(t, output.String(), `"e2e-nongroovy-tests":false`) assert.Contains(t, output.String(), "wait-for-gha-images=false") } + +func TestGHADispatchCILabelFailure(t *testing.T) { + for name, test := range map[string]struct { + labels, fork, draft, uiOnly string + nongroovy, byodb, konflux, waitGHA bool + }{ + "ready PR without labels": {nongroovy: true, waitGHA: true}, + "draft without labels": {draft: "true"}, + "UI only": {uiOnly: "true", waitGHA: true}, + "explicit job on draft": { + labels: "ci-dispatcher-enforce,e2e-byodb-tests", draft: "true", + byodb: true, waitGHA: true, + }, + "Konflux label": { + labels: "konflux-build", nongroovy: true, konflux: true, waitGHA: true, + }, + "stale enforcement label": { + labels: "ci-dispatcher-enforce", nongroovy: true, waitGHA: true, + }, + "fork with explicit job": { + labels: "ci-dispatcher-enforce,e2e-byodb-tests,konflux-build", fork: "true", + }, + } { + t.Run(name, func(t *testing.T) { + t.Setenv("CI_SELECT_ROOT", t.TempDir()) + t.Setenv("GITHUB_EVENT_NAME", "pull_request") + t.Setenv("CI_PR", "23403") + t.Setenv("CI_REPO", "stackrox/stackrox") + t.Setenv("CI_IS_FORK", test.fork) + t.Setenv("CI_IS_DRAFT", test.draft) + t.Setenv("CI_UI_ONLY", test.uiOnly) + t.Setenv("PR_LABELS", test.labels) + outputPath := filepath.Join(t.TempDir(), "output") + t.Setenv("GITHUB_OUTPUT", outputPath) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/repos/stackrox/stackrox/issues/23403/labels", r.URL.Path) + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) + var output, diagnostic bytes.Buffer + require.NoError(t, ghaDispatchCI(t.Context(), &output, &diagnostic)) + assert.Contains(t, diagnostic.String(), "using event labels and existing dispatch rules with enforcement disabled") + assert.NotContains(t, diagnostic.String(), "CI decision unreadable") + assert.Contains(t, output.String(), fmt.Sprintf(`"e2e-nongroovy-tests":%t`, test.nongroovy)) + assert.Contains(t, output.String(), fmt.Sprintf(`"e2e-byodb-tests":%t`, test.byodb)) + assert.Contains(t, output.String(), fmt.Sprintf(`"e2e-qa-tests-gke-konflux":%t`, test.konflux)) + assert.Contains(t, output.String(), fmt.Sprintf("wait-for-gha-images=%t", test.waitGHA)) + assert.Contains(t, output.String(), fmt.Sprintf("wait-for-konflux-images=%t", test.konflux)) + written, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Equal(t, output.String(), string(written)) + }) + } +} diff --git a/ci/select/github.go b/ci/select/github.go index 28e41090d9a39..1c4546ce9fd4e 100644 --- a/ci/select/github.go +++ b/ci/select/github.go @@ -70,7 +70,7 @@ func (g githubClient) get(ctx context.Context, path string, destination any) err case <-time.After(time.Duration(attempt+1) * time.Second): } } - return fmt.Errorf("GitHub request failed") + return errors.New("GitHub request failed") } func repositoryPath(repo, pr string) (string, error) { @@ -99,7 +99,7 @@ func (g githubClient) labels(ctx context.Context, repo, pr string) ([]string, er return nil, err } if items == nil { - return nil, fmt.Errorf("GitHub labels response must be an array") + return nil, errors.New("GitHub labels response must be an array") } for _, item := range items { labels = append(labels, item.Name) @@ -122,7 +122,7 @@ func (g githubClient) comments(ctx context.Context, repo, pr string) ([]comment, return nil, err } if items == nil { - return nil, fmt.Errorf("GitHub comments response must be an array") + return nil, errors.New("GitHub comments response must be an array") } comments = append(comments, items...) if len(items) < 100 { diff --git a/ci/select/prow.go b/ci/select/prow.go index 370fb848af0e2..4057bdee3128a 100644 --- a/ci/select/prow.go +++ b/ci/select/prow.go @@ -15,7 +15,10 @@ import ( "github.com/pkg/errors" ) -var testLine = regexp.MustCompile(`^/test\b(.*)$`) +var ( + testLine = regexp.MustCompile(`^/test\b(.*)$`) + rehearsalEnforceLabel = regexp.MustCompile(`/label:\s*ci-dispatcher-enforce`) +) type comment struct { CreatedAt string `json:"created_at"` @@ -63,7 +66,7 @@ func readComments(path string) ([]comment, error) { return nil, errors.Wrap(err, "decode comments file") } if comments == nil { - return nil, fmt.Errorf("comments file must be a list") + return nil, errors.New("comments file must be a list") } return comments, nil } @@ -97,7 +100,7 @@ func prowCI(ctx context.Context, job string, input io.Reader, diagnostic io.Writ enforced := slices.Contains(labels, enforceLabel) if meta.repo == "openshift/release" { // Rehearsal PRs carry the requested label in their description. - enforced = regexp.MustCompile(`/label:\s*ci-dispatcher-enforce`).MatchString(pr.Body) + enforced = rehearsalEnforceLabel.MatchString(pr.Body) } files, unknown := changedFiles(ctx, os.Getenv("PULL_BASE_SHA"), "HEAD") head := envOr("PULL_PULL_SHA", "HEAD") diff --git a/ci/select/resolver.go b/ci/select/resolver.go index b17c3cfe2f417..d10ef5abfa6c3 100644 --- a/ci/select/resolver.go +++ b/ci/select/resolver.go @@ -167,7 +167,7 @@ func openDecision(rulesPath, defaultsPath string, files, labels []string, unknow } m, err := loadMapping(rulesPath) if err == nil && unknown { - err = fmt.Errorf("changed files are unknown") + err = errors.New("changed files are unknown") } if err == nil { var resolved result diff --git a/tools/roxvet/analyzers/validateimports/analyzer.go b/tools/roxvet/analyzers/validateimports/analyzer.go index 8330b7a9fdd09..e9d8d5e8f0abf 100644 --- a/tools/roxvet/analyzers/validateimports/analyzer.go +++ b/tools/roxvet/analyzers/validateimports/analyzer.go @@ -23,6 +23,7 @@ var ( // Keep this in alphabetic order. validRoots = set.NewFrozenStringSet( "central", + "ci/select", "compliance", "config-controller", "govulncheck", From 3dee4f603dfe9e5c629be1314a7d17c451f94c71 Mon Sep 17 00:00:00 2001 From: Piotr Rygielski <114479+vikin91@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:27:50 +0200 Subject: [PATCH 4/5] Update job names, address feedback --- .github/workflows/e2e-dispatch.yaml | 1 + ci/decision-defaults | 2 +- ci/select/README.md | 2 ++ ci/select/gha_test.go | 35 ++++++++++++++++------------- ci/select/prow.go | 2 +- ci/select/prow_test.go | 35 +++++++++++++++++++++++++++++ ci/test-domains.toml | 16 ++++++------- 7 files changed, 67 insertions(+), 26 deletions(-) diff --git a/.github/workflows/e2e-dispatch.yaml b/.github/workflows/e2e-dispatch.yaml index 9c10a0a451b51..987e45354853c 100644 --- a/.github/workflows/e2e-dispatch.yaml +++ b/.github/workflows/e2e-dispatch.yaml @@ -43,6 +43,7 @@ jobs: with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false # TODO(ROX-37371): Decide binary distribution and recovery when the selector # cannot compile or run. A prebuilt binary removes compilation failures, diff --git a/ci/decision-defaults b/ci/decision-defaults index 44005b969a572..a7a91e608f98e 100644 --- a/ci/decision-defaults +++ b/ci/decision-defaults @@ -13,7 +13,7 @@ wait-for-images run e2e-byodb-tests run e2e-db-backup-restore-tests run e2e-gke-upgrade-tests run -e2e-nongroovy-tests run +e2e-nongroovy-tests-gke run e2e-qa-tests-gke skip e2e-qa-tests-gke-konflux skip e2e-qa-tests-ocp-4-22 skip diff --git a/ci/select/README.md b/ci/select/README.md index 5045fa38d6d6c..4e4bacf1658ca 100644 --- a/ci/select/README.md +++ b/ci/select/README.md @@ -187,6 +187,8 @@ These integration costs still need validation in real CI: - The rules and job inventory are samples, not a reviewed production map. In particular, the sample OCP target name and the dispatched Konflux target name differ; an unenrolled target follows existing dispatch rules. + The nongroovy GHA target is `e2e-nongroovy-tests-gke`; its separate + `e2e-nongroovy-tests-gke-konflux` job is not enrolled in the sample rules. - `image-wait` still requests image waiting on every PR. A language port does not make those waits cheaper or change required-check behavior. diff --git a/ci/select/gha_test.go b/ci/select/gha_test.go index ee74ae26451d2..bdf5f78b65dd5 100644 --- a/ci/select/gha_test.go +++ b/ci/select/gha_test.go @@ -31,27 +31,30 @@ func TestGHAOutputs(t *testing.T) { } func TestDispatchPlan(t *testing.T) { - d := defaults{actions: map[string]string{"e2e-nongroovy-tests": "run", "wait-for-images": "run"}} + d := defaults{actions: map[string]string{"e2e-nongroovy-tests-gke": "run", "wait-for-images": "run"}} for name, test := range map[string]struct { - input dispatchInput - resolved *result - nongroovy, byodb, waitGHA, waitKonflux bool + input dispatchInput + resolved *result + nongroovy, nongroovyKonflux, byodb, waitGHA, waitKonflux bool }{ - "push": {input: dispatchInput{}, nongroovy: true, byodb: true, waitGHA: true, waitKonflux: true}, - "ready PR": {input: dispatchInput{pullRequest: true}, nongroovy: true, waitGHA: true}, - "draft": {input: dispatchInput{pullRequest: true, draft: true}}, - "UI": {input: dispatchInput{pullRequest: true, uiOnly: true}, waitGHA: true}, - "explicit label": {input: dispatchInput{pullRequest: true, draft: true, labels: []string{"e2e-byodb-tests"}}, byodb: true, waitGHA: true}, - "Konflux": {input: dispatchInput{pullRequest: true, labels: []string{"konflux-build"}}, nongroovy: true, waitGHA: true, waitKonflux: true}, - "enforce skip": {input: dispatchInput{pullRequest: true, enforce: true}, resolved: &result{jobs: map[string]bool{}}}, - "shadow": {input: dispatchInput{pullRequest: true}, resolved: &result{jobs: map[string]bool{}}, nongroovy: true, waitGHA: true}, - "fork": {input: dispatchInput{pullRequest: true, fork: true, enforce: true}, resolved: &result{jobs: map[string]bool{"e2e-nongroovy-tests": true, "wait-for-images": true}}}, + "push": {input: dispatchInput{}, nongroovy: true, nongroovyKonflux: true, byodb: true, waitGHA: true, waitKonflux: true}, + "ready PR": {input: dispatchInput{pullRequest: true}, nongroovy: true, waitGHA: true}, + "draft": {input: dispatchInput{pullRequest: true, draft: true}}, + "UI": {input: dispatchInput{pullRequest: true, uiOnly: true}, waitGHA: true}, + "explicit label": {input: dispatchInput{pullRequest: true, draft: true, labels: []string{"e2e-byodb-tests"}}, byodb: true, waitGHA: true}, + "Konflux": {input: dispatchInput{pullRequest: true, labels: []string{"konflux-build"}}, nongroovy: true, nongroovyKonflux: true, waitGHA: true, waitKonflux: true}, + "explicit nongroovy Konflux": {input: dispatchInput{pullRequest: true, draft: true, labels: []string{"e2e-nongroovy-tests-gke-konflux"}}, nongroovyKonflux: true, waitGHA: true}, + "enforce skip": {input: dispatchInput{pullRequest: true, enforce: true}, resolved: &result{jobs: map[string]bool{}}}, + "shadow": {input: dispatchInput{pullRequest: true}, resolved: &result{jobs: map[string]bool{}}, nongroovy: true, waitGHA: true}, + "fork": {input: dispatchInput{pullRequest: true, fork: true, enforce: true}, resolved: &result{jobs: map[string]bool{"e2e-nongroovy-tests-gke": true, "wait-for-images": true}}}, } { t.Run(name, func(t *testing.T) { plan := dispatchPlan(test.input, test.resolved, d) jobs := map[string]bool{} require.NoError(t, json.Unmarshal([]byte(plan["jobs"]), &jobs)) - assert.Equal(t, test.nongroovy, jobs["e2e-nongroovy-tests"]) + assert.Equal(t, test.nongroovy, jobs["e2e-nongroovy-tests-gke"]) + assert.Equal(t, test.nongroovyKonflux, jobs["e2e-nongroovy-tests-gke-konflux"]) + assert.NotContains(t, jobs, "e2e-nongroovy-tests") assert.Equal(t, test.byodb, jobs["e2e-byodb-tests"]) assert.Equal(t, fmt.Sprint(test.waitGHA), plan["wait-for-gha-images"]) assert.Equal(t, fmt.Sprint(test.waitKonflux), plan["wait-for-konflux-images"]) @@ -102,7 +105,7 @@ func TestGHADispatchCI(t *testing.T) { t.Setenv("GITHUB_API_URL", server.URL) var output bytes.Buffer require.NoError(t, ghaDispatchCI(t.Context(), &output, &bytes.Buffer{})) - assert.Contains(t, output.String(), `"e2e-nongroovy-tests":false`) + assert.Contains(t, output.String(), `"e2e-nongroovy-tests-gke":false`) assert.Contains(t, output.String(), "wait-for-gha-images=false") } @@ -149,7 +152,7 @@ func TestGHADispatchCILabelFailure(t *testing.T) { require.NoError(t, ghaDispatchCI(t.Context(), &output, &diagnostic)) assert.Contains(t, diagnostic.String(), "using event labels and existing dispatch rules with enforcement disabled") assert.NotContains(t, diagnostic.String(), "CI decision unreadable") - assert.Contains(t, output.String(), fmt.Sprintf(`"e2e-nongroovy-tests":%t`, test.nongroovy)) + assert.Contains(t, output.String(), fmt.Sprintf(`"e2e-nongroovy-tests-gke":%t`, test.nongroovy)) assert.Contains(t, output.String(), fmt.Sprintf(`"e2e-byodb-tests":%t`, test.byodb)) assert.Contains(t, output.String(), fmt.Sprintf(`"e2e-qa-tests-gke-konflux":%t`, test.konflux)) assert.Contains(t, output.String(), fmt.Sprintf("wait-for-gha-images=%t", test.waitGHA)) diff --git a/ci/select/prow.go b/ci/select/prow.go index 4057bdee3128a..3af0de3d4ce54 100644 --- a/ci/select/prow.go +++ b/ci/select/prow.go @@ -17,7 +17,7 @@ import ( var ( testLine = regexp.MustCompile(`^/test\b(.*)$`) - rehearsalEnforceLabel = regexp.MustCompile(`/label:\s*ci-dispatcher-enforce`) + rehearsalEnforceLabel = regexp.MustCompile(`(?m)^[ \t]*/label:[ \t]*ci-dispatcher-enforce(?:[ \t\r]|$)`) ) type comment struct { diff --git a/ci/select/prow_test.go b/ci/select/prow_test.go index 6b944130d806e..0c0ffc90f806f 100644 --- a/ci/select/prow_test.go +++ b/ci/select/prow_test.go @@ -62,6 +62,41 @@ func TestProwCI(t *testing.T) { assert.NotEqual(t, 3, code) } +func TestProwCIRehearsalEnforcement(t *testing.T) { + t.Setenv("CI_SELECT_ROOT", "../..") + t.Setenv("PULL_BASE_SHA", "missing-ref") + t.Setenv("PULL_PULL_SHA", "HEAD") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "[]") + })) + defer server.Close() + t.Setenv("GITHUB_API_URL", server.URL) + for body, want := range map[string]string{ + "/label: ci-dispatcher-enforce": "skip", + "description\n/label: ci-dispatcher-enforce\n": "skip", + " /label:\tci-dispatcher-enforce\r\n": "skip", + "/label: ci-dispatcher-enforce another-label": "skip", + "/label: ci-dispatcher-enforced": "run", + "/label: ci-dispatcher-enforce-extra": "run", + "/label: ci-dispatcher-enforce_extra": "run", + "/label: ci-dispatcher-enforce.extra": "run", + "/label: ci-dispatcher-enforce/extra": "run", + "quoted /label: ci-dispatcher-enforce": "run", + "/label:\nci-dispatcher-enforce": "run", + "": "run", + } { + t.Run(body, func(t *testing.T) { + pr := pullRequest{Number: 23403, Body: body} + pr.Base.Repo.FullName = "openshift/release" + input, err := json.Marshal(pr) + require.NoError(t, err) + action, err := prowCI(t.Context(), "gke-qa-e2e-tests", bytes.NewReader(input), &bytes.Buffer{}) + require.NoError(t, err) + assert.Equal(t, want, action) + }) + } +} + func TestProwCIComments(t *testing.T) { t.Setenv("CI_SELECT_ROOT", "../..") t.Setenv("PULL_BASE_SHA", "missing-ref") diff --git a/ci/test-domains.toml b/ci/test-domains.toml index 6a9062d94f2cf..a1501f5f5c6a5 100644 --- a/ci/test-domains.toml +++ b/ci/test-domains.toml @@ -15,7 +15,7 @@ jobs = [ "e2e-byodb-tests", "e2e-db-backup-restore-tests", "e2e-gke-upgrade-tests", - "e2e-nongroovy-tests", + "e2e-nongroovy-tests-gke", "e2e-qa-tests-gke", "e2e-qa-tests-gke-konflux", "e2e-qa-tests-ocp-4-22", @@ -111,7 +111,7 @@ skip = [ "e2e-byodb-tests", "e2e-db-backup-restore-tests", "e2e-gke-upgrade-tests", - "e2e-nongroovy-tests", + "e2e-nongroovy-tests-gke", "e2e-qa-tests-gke", "e2e-qa-tests-gke-konflux", "e2e-qa-tests-ocp-4-22", @@ -137,7 +137,7 @@ skip = [ "e2e-byodb-tests", "e2e-db-backup-restore-tests", "e2e-gke-upgrade-tests", - "e2e-nongroovy-tests", + "e2e-nongroovy-tests-gke", "e2e-qa-tests-gke", "e2e-qa-tests-gke-konflux", "e2e-qa-tests-ocp-4-22", @@ -223,7 +223,7 @@ skip = [ "e2e-byodb-tests", "e2e-db-backup-restore-tests", "e2e-gke-upgrade-tests", - "e2e-nongroovy-tests", + "e2e-nongroovy-tests-gke", "gke-nongroovy-e2e-tests", "gke-qa-e2e-tests", "e2e-qa-tests-gke", @@ -246,14 +246,14 @@ name = "operator-only" when = "every-file-matches" paths = ['^operator/', '\.crd\.yaml$', '^config-controller/'] run = ["check-crd-compatibility", "go"] -skip = ["sensor-integration-tests", "go-postgres", "db-integration-tests", "e2e-nongroovy-tests", "gke-nongroovy-e2e-tests"] +skip = ["sensor-integration-tests", "go-postgres", "db-integration-tests", "e2e-nongroovy-tests-gke", "gke-nongroovy-e2e-tests"] # roxctl-Only [[rules]] name = "roxctl-only" when = "every-file-matches" paths = ['^roxctl/'] -run = ["local-roxctl-tests", "go", "e2e-nongroovy-tests"] +run = ["local-roxctl-tests", "go", "e2e-nongroovy-tests-gke"] skip = ["sensor-integration-tests", "go-postgres", "db-integration-tests", "gke-ui-e2e-tests", "e2e-byodb-tests", "e2e-db-backup-restore-tests", "e2e-gke-upgrade-tests"] # Tier 9: Central/Sensor Separation (Aggressive) @@ -340,7 +340,7 @@ skip = ["e2e-db-backup-restore-tests"] name = "skip-e2e-nongroovy" when = "every-file-matches" paths = ['^ui/', '^docs/', '\.md$', '^operator/'] -skip = ["e2e-nongroovy-tests", "gke-nongroovy-e2e-tests"] +skip = ["e2e-nongroovy-tests-gke", "gke-nongroovy-e2e-tests"] [[rules]] name = "skip-e2e-upgrade-all" @@ -365,7 +365,7 @@ wait-for-images = ["should-dispatch"] e2e-byodb-tests = ["wait-for-images"] e2e-db-backup-restore-tests = ["wait-for-images"] e2e-gke-upgrade-tests = ["wait-for-images"] -e2e-nongroovy-tests = ["wait-for-images"] +e2e-nongroovy-tests-gke = ["wait-for-images"] e2e-qa-tests-gke = ["wait-for-images"] e2e-qa-tests-ocp-4-22 = ["wait-for-images"] e2e-qa-tests-gke-konflux = ["wait-for-images"] From e684e8e168db97ed4f6dd6910926bc214dea28d7 Mon Sep 17 00:00:00 2001 From: Piotr Rygielski <114479+vikin91@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:43:52 +0200 Subject: [PATCH 5/5] Separate resolver tests from build --- .github/workflows/ci-decision.yaml | 6 ---- .github/workflows/ci-select-tests.yaml | 46 ++++++++++++++++++++++++++ .github/workflows/style.yaml | 18 ---------- ci/decision-defaults | 1 + ci/select/resolver_test.go | 45 +++++++++++++++++++++++++ ci/test-domains.toml | 16 +++++++++ 6 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci-select-tests.yaml diff --git a/.github/workflows/ci-decision.yaml b/.github/workflows/ci-decision.yaml index 43dde9956cf73..bb70d13202534 100644 --- a/.github/workflows/ci-decision.yaml +++ b/.github/workflows/ci-decision.yaml @@ -28,12 +28,6 @@ jobs: - uses: ./.github/actions/ci-select - - name: Test CI selector - run: go test -race ./ci/select - - - name: Validate CI rules - run: ci/select/bin/ci-select validate - - name: Print the CI decision env: CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} diff --git a/.github/workflows/ci-select-tests.yaml b/.github/workflows/ci-select-tests.yaml new file mode 100644 index 0000000000000..2224e8bc4d4fd --- /dev/null +++ b/.github/workflows/ci-select-tests.yaml @@ -0,0 +1,46 @@ +name: CI selector tests + +on: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] + push: + branches: [master, 'release-*'] + +permissions: + contents: read + +jobs: + selector-tests: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Build CI selector + continue-on-error: true + uses: ./.github/actions/ci-select + + - name: Read the CI decision + id: ci-decision + if: github.event_name == 'pull_request' + env: + CI_ENFORCE: ${{ contains(github.event.pull_request.labels.*.name, 'ci-dispatcher-enforce') }} + CI_REPO: ${{ github.repository }} + CI_PR: ${{ github.event.pull_request.number }} + CI_COMMIT: ${{ github.event.pull_request.head.sha }} + CI_BASE: ${{ github.event.pull_request.base.sha }} + CI_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: ci/select/gha-gate.sh selector-tests + + - name: Test CI selector + if: steps.ci-decision.outputs.action != 'skip' + run: go test -race ./ci/select + + - name: Validate CI rules + if: steps.ci-decision.outputs.action != 'skip' + run: ci/select/bin/ci-select validate diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml index 5d35b70794a19..4bd0257d7d625 100644 --- a/.github/workflows/style.yaml +++ b/.github/workflows/style.yaml @@ -430,24 +430,6 @@ jobs: if: steps.ci-decision.outputs.action != 'skip' run: make -C .openshift-ci lint - validate-test-domains: - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: Build CI selector - uses: ./.github/actions/ci-select - - - name: Validate test-domains.toml - run: ci/select/bin/ci-select validate - - - name: Run resolver unit tests - run: go test -race ./ci/select - slack-on-style-failure: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/ci/decision-defaults b/ci/decision-defaults index a7a91e608f98e..9e652cffe7dd8 100644 --- a/ci/decision-defaults +++ b/ci/decision-defaults @@ -5,6 +5,7 @@ # opt-in label. A change that is only under ui/ is a separate filter. style-check run +selector-tests run go run go-postgres run sensor-integration-tests run diff --git a/ci/select/resolver_test.go b/ci/select/resolver_test.go index 81635ae7beda1..f7fd5f43bb587 100644 --- a/ci/select/resolver_test.go +++ b/ci/select/resolver_test.go @@ -151,6 +151,49 @@ func TestCurrentRules(t *testing.T) { } } +func TestSelectorTestsGate(t *testing.T) { + for name, test := range map[string]struct { + files []string + labels []string + unknown bool + enforce bool + want string + }{ + "selector": {files: []string{"ci/select/resolver.go"}, enforce: true, want: "run"}, + "selector tests": {files: []string{"ci/select/resolver_test.go"}, enforce: true, want: "run"}, + "test fixtures": {files: []string{"ci/select/testdata/edge_cases.json"}, enforce: true, want: "run"}, + "rules": {files: []string{"ci/test-domains.toml"}, enforce: true, want: "run"}, + "defaults": {files: []string{"ci/decision-defaults"}, enforce: true, want: "run"}, + "Prow dispatcher": {files: []string{".openshift-ci/dispatch.sh"}, enforce: true, want: "skip"}, + "build action": {files: []string{".github/actions/ci-select/action.yaml"}, enforce: true, want: "skip"}, + "test workflow": {files: []string{".github/workflows/ci-select-tests.yaml"}, enforce: true, want: "skip"}, + "GHA dispatcher": {files: []string{".github/workflows/e2e-dispatch.yaml"}, enforce: true, want: "skip"}, + "decision workflow": {files: []string{".github/workflows/ci-decision.yaml"}, enforce: true, want: "skip"}, + "CRD workflow": {files: []string{".github/workflows/check-crd-compatibility.yaml"}, enforce: true, want: "skip"}, + "scanner workflow": {files: []string{".github/workflows/scanner-db-integration-tests.yaml"}, enforce: true, want: "skip"}, + "style workflow": {files: []string{".github/workflows/style.yaml"}, enforce: true, want: "skip"}, + "unit workflow": {files: []string{".github/workflows/unit-tests.yaml"}, enforce: true, want: "skip"}, + "dependencies": {files: []string{"go.mod"}, enforce: true, want: "run"}, + "dependency checksums": {files: []string{"go.sum"}, enforce: true, want: "run"}, + "mixed changes": {files: []string{"ci/select/gha.go", "sensor/common/example.go"}, enforce: true, want: "run"}, + "product": {files: []string{"sensor/common/example.go"}, enforce: true, want: "skip"}, + "unmatched": {files: []string{"notes/todo.txt"}, enforce: true, want: "skip"}, + "UI": {files: []string{"ui/example.tsx"}, enforce: true, want: "skip"}, + "docs": {files: []string{"README.md"}, enforce: true, want: "skip"}, + "no changes": {enforce: true, want: "skip"}, + "run all": {files: []string{"README.md"}, labels: []string{"ci-run-all-tests"}, enforce: true, want: "run"}, + "shadow": {files: []string{"README.md"}, want: "run"}, + "unknown diff": {unknown: true, enforce: true, want: "run"}, + } { + t.Run(name, func(t *testing.T) { + resolved, d, err := openDecision("../test-domains.toml", "../decision-defaults", + test.files, test.labels, test.unknown, metadata{}) + require.NoError(t, err) + assert.Equal(t, test.want, gateAction("selector-tests", resolved.jobs, d, test.enforce, false)) + }) + } +} + func TestMissingRulesUseDefaults(t *testing.T) { resolved, _, err := openDecision("testdata/missing.toml", "testdata/defaults", nil, nil, false, metadata{}) require.NoError(t, err) @@ -206,9 +249,11 @@ func TestResolverUnmatchedFileUsesDefaults(t *testing.T) { require.NoError(t, err) expected, err := parseDecision(d.decision()) require.NoError(t, err) + delete(expected, "selector-tests") resolved, err := resolve([]string{"notes/todo.txt"}, nil, m, d, metadata{}) require.NoError(t, err) assert.Equal(t, expected, resolved.jobs) + assert.Contains(t, resolved.log, `rule "skip-selector-tests" skips target "selector-tests"`) } func TestResolverCheckRequiresJob(t *testing.T) { diff --git a/ci/test-domains.toml b/ci/test-domains.toml index a1501f5f5c6a5..161f236f79421 100644 --- a/ci/test-domains.toml +++ b/ci/test-domains.toml @@ -7,6 +7,7 @@ version = 1 jobs = [ "style-check", + "selector-tests", "go", "go-postgres", "sensor-integration-tests", @@ -150,6 +151,21 @@ skip = [ "sensor-integration-tests", ] +[[rules]] +name = "selector-tests" +when = "any-file-matches" +paths = [ + '^ci/select/', + '^ci/test-domains\.toml$', + '^ci/decision-defaults$', +] +run = ["selector-tests"] + +[[rules]] +name = "skip-selector-tests" +when = "always" +skip = ["selector-tests"] + # Tier 5: Always-Run Tests [[rules]] name = "style"