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..67472af8a9bb9 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 @@ -77,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 @@ -87,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" @@ -96,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/ci-decision.yaml b/.github/workflows/ci-decision.yaml new file mode 100644 index 0000000000000..bb70d13202534 --- /dev/null +++ b/.github/workflows/ci-decision.yaml @@ -0,0 +1,40 @@ +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: 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/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/e2e-dispatch.yaml b/.github/workflows/e2e-dispatch.yaml index 4335fefeb05cb..987e45354853c 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,50 @@ 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 }} + persist-credentials: false + + # 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 + 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 }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + 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..4bd0257d7d625 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,17 +395,39 @@ 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 slack-on-style-failure: 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/decision-defaults b/ci/decision-defaults new file mode 100644 index 0000000000000..9e652cffe7dd8 --- /dev/null +++ b/ci/decision-defaults @@ -0,0 +1,34 @@ +# 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 +selector-tests 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-gke 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..4e4bacf1658ca --- /dev/null +++ b/ci/select/README.md @@ -0,0 +1,211 @@ +# 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. 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 +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. + +### 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 + 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. + 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. + +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..237535852be2c --- /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 errors.New("gate needs --job") + } + if o.unknown == (o.files != "") { + return errors.New("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 errors.New("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..f90753ab9cdfa --- /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, errors.New("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 errors.New("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 errors.New("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 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 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] { + 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..50bd7cc325565 --- /dev/null +++ b/ci/select/decision.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" +) + +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, errors.New("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, errors.New("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..e0bb868796c7e --- /dev/null +++ b/ci/select/gha.go @@ -0,0 +1,222 @@ +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 errors.New("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-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}, +} + +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 { + 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) + } + } + 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..bdf5f78b65dd5 --- /dev/null +++ b/ci/select/gha_test.go @@ -0,0 +1,165 @@ +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-gke": "run", "wait-for-images": "run"}} + for name, test := range map[string]struct { + input dispatchInput + resolved *result + nongroovy, nongroovyKonflux, byodb, waitGHA, waitKonflux bool + }{ + "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-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"]) + }) + } +} + +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-gke":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-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)) + 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 new file mode 100644 index 0000000000000..1c4546ce9fd4e --- /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 errors.New("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, errors.New("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, errors.New("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..3af0de3d4ce54 --- /dev/null +++ b/ci/select/prow.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" +) + +var ( + testLine = regexp.MustCompile(`^/test\b(.*)$`) + rehearsalEnforceLabel = regexp.MustCompile(`(?m)^[ \t]*/label:[ \t]*ci-dispatcher-enforce(?:[ \t\r]|$)`) +) + +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, errors.New("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 = rehearsalEnforceLabel.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..0c0ffc90f806f --- /dev/null +++ b/ci/select/prow_test.go @@ -0,0 +1,167 @@ +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 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") + 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..d10ef5abfa6c3 --- /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 = errors.New("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..f7fd5f43bb587 --- /dev/null +++ b/ci/select/resolver_test.go @@ -0,0 +1,362 @@ +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 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) + 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) + 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) { + 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..161f236f79421 --- /dev/null +++ b/ci/test-domains.toml @@ -0,0 +1,387 @@ +# 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", + "selector-tests", + "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-gke", + "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-gke", + "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-gke", + "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", +] + +[[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" +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", + "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", "gke-nongroovy-e2e-tests"] + +# roxctl-Only +[[rules]] +name = "roxctl-only" +when = "every-file-matches" +paths = ['^roxctl/'] +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) + +# 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", "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-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"] 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",