From eb8e4316ec42278fb4f2f568f6da4339a07cb26a Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Thu, 29 Oct 2026 13:06:14 +0800 Subject: [PATCH] ci: backport scoped heavy CI to narthex (#10053) * ci: backport scoped heavy CI to narthex Backport #9927 and the CI-only changes from #10043. Adapt script references to narthex, preserve manual platform checks, and document the default-branch schedule limitation. Upstream-Commit: ebb7e22566f8cb6246f39eab01e32488c682a2c8 Upstream-Commit: e87766d7ef64a33ea02fce31d2cb2700903f0cef Signed-off-by: WenyXu * ci: refresh narthex compatibility release window Signed-off-by: WenyXu --------- Signed-off-by: WenyXu --- .../actions/github-script-tests/action.yml | 52 + .github/ci/README.md | 170 +++ .github/ci/ci_scope.py | 623 ++++++++++ .github/ci/ci_scope_rules.py | 277 +++++ .github/ci/scope-rules.toml | 281 +++++ .github/ci/tests/fixtures/scope-cases.toml | 158 +++ .github/ci/tests/test_ci_scope.py | 1018 +++++++++++++++++ .github/heavy-ci.md | 37 + .github/scripts/heavy-ci-policy.test.cjs | 118 ++ .github/scripts/tests/test_ci_slash.py | 2 +- .github/workflows/checks.yml | 54 +- .github/workflows/integration.yml | 191 +++- .github/workflows/rust.yml | 71 +- Makefile | 4 + tests/compatibility/ci.toml | 2 +- 15 files changed, 2927 insertions(+), 131 deletions(-) create mode 100644 .github/actions/github-script-tests/action.yml create mode 100644 .github/ci/README.md create mode 100755 .github/ci/ci_scope.py create mode 100755 .github/ci/ci_scope_rules.py create mode 100644 .github/ci/scope-rules.toml create mode 100644 .github/ci/tests/fixtures/scope-cases.toml create mode 100755 .github/ci/tests/test_ci_scope.py create mode 100644 .github/heavy-ci.md create mode 100644 .github/scripts/heavy-ci-policy.test.cjs diff --git a/.github/actions/github-script-tests/action.yml b/.github/actions/github-script-tests/action.yml new file mode 100644 index 00000000000..c7aba309383 --- /dev/null +++ b/.github/actions/github-script-tests/action.yml @@ -0,0 +1,52 @@ +name: GitHub Script Tests +description: Validate CI scripts before starting expensive jobs +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Run Node GitHub script tests + shell: bash + run: node --test .github/scripts/query-regression-comment.test.cjs .github/scripts/heavy-ci-policy.test.cjs + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + - name: Run check-version script tests + shell: bash + run: uv run --no-project python .github/scripts/check-version-test.py + - name: Run release Linux architecture tests + shell: bash + run: python3 .github/scripts/release-linux-architectures-test.py + - name: Run previous-release-tag script tests + shell: bash + run: python3 .github/scripts/previous-release-tag-test.py + - name: Run enterprise license check tests + shell: bash + run: python3 scripts/check-enterprise-license-test.py + - name: Run fuzz orchestration script tests + shell: bash + run: .github/scripts/run-fuzz-targets-test.sh + - name: Run CI comment-command tests + shell: bash + run: python3 .github/scripts/tests/test_ci_slash.py + - name: Run CI scope gate tests + shell: bash + run: python3 .github/ci/tests/test_ci_scope.py + - name: Run metric snapshot benchmark tests + shell: bash + run: python3 -m unittest discover -s .github/scripts/tests -p 'test_metric_snapshot*.py' + - name: Run query-regression Python tooling tests + shell: bash + run: | + python3 tests/perf/test_query_regression_runner_compaction_toctou.py + python3 tests/perf/test_query_regression_runner_otlp_trace_load.py + python3 tests/perf/test_query_regression_summary_otlp.py + python3 tests/perf/test_query_regression_case_selection.py + python3 tests/perf/test_query_regression_nightly_refs.py + python3 tests/perf/test_query_regression_slash.py + python3 tests/perf/test_aliyun_ecs_runner_scripts.py + python3 tests/perf/test_ci_runner_scripts.py + python3 tests/perf/test_agent_observability_summary.py + python3 tests/perf/test_tracesbench_summary.py diff --git a/.github/ci/README.md b/.github/ci/README.md new file mode 100644 index 00000000000..1ead30842ec --- /dev/null +++ b/.github/ci/README.md @@ -0,0 +1,170 @@ +# Integration CI admission gate + +`integration.yml` filters its expensive jobs — fuzz, export/import E2E, and the +sqlness KvBackend legs — by the diff. Fuzz additionally follows the event policy +in [Heavy CI scheduling](../heavy-ci.md): PR opening or ready-for-review events, +scheduled runs, and explicit manual profiles. A scope verdict cannot override +that event policy. + +The gate can only *skip* work, never add it, and it fails open: when the verdict +is missing or uncertain, every gated job runs. + +| File | Role | +|---|---| +| `ci_scope.py` | the classifier: reads the diff, writes `$GITHUB_OUTPUT` | +| `scope-rules.toml` | the policy: which paths select which suite, as data | +| `ci_scope_rules.py` | loads and validates the policy; `--check-paths` | +| `tests/` | unit tests, and `fixtures/scope-cases.toml` golden cases | + +Two words appear everywhere below. A **suite** is one matrix leg of a gated job, +named `/`. A **gate** is one `$GITHUB_OUTPUT` key that switches a +whole job. + +## What is gated + +Only `integration.yml`, and only these jobs: + +| Gated job | Runs | +|---|---| +| `prepare-fuzz-targets` | the fuzz targets only | +| `fuzztest` | standalone fuzz, 2 legs | +| `unstable-fuzztest` | unstable standalone create-table fuzz, 1 leg | +| `distributed-fuzztest` | distributed fuzz with remote WAL, 4 legs | +| `distributed-fuzztest-with-chaos` | Chaos Mesh, 3 legs | +| `build-greptime-ci` | the profile-CI binary only | +| `export-import-v2-e2e` | CLI `COPY DATABASE` export/import against S3 | +| `sqlness-kvbackend` | sqlness with PostgreSQL and MySQL as the metadata store | + +The legs themselves are the `matrix` entries in `integration.yml`; the rules +file repeats their ids so a verdict can name them. + +Everything else always runs: `rust.yml`, `checks.yml`, `docs.yml`, the debug +`build`, the required `sqlness` job (Basic, Remote WAL, Flat format), `compat` +and `compat-updater-check`. None of the gated jobs is in the required-check +matrix in `docs.yml`, so a skipped job can never block a merge. + +Two choices follow from that rule. The two KvBackend legs sit in their own +`sqlness-kvbackend` job because a job-level `if:` cannot read `matrix`, so +gating them inside `sqlness` would skip required checks and make the pull +request unmergeable. The multi-language SDK job stays ungated: it takes about +three minutes, less than the cost of getting its verdict wrong. + +## How a verdict is made + +First match wins: + +1. **Forced** — `merge_group`, `schedule`, `workflow_dispatch`, the `ci:full` + label or `CI_SCOPE_FORCE_ALL`. All scope outputs allow execution; job event + conditions and manual profile selection still apply. +2. **Too large to judge** — more than 300 changed files. Everything runs. +3. **Always-run file changed** — `Cargo.toml`, `Cargo.lock`, `tests-fuzz/**`, + this directory, or anything else that changes what the suites build or how + they start. Everything runs. +4. **Uncovered change** — a path under `src/` that no suite claims. This is the + net for a crate added, split or renamed after the rules were written. + Everything runs. +5. **Path rules** — the suites whose globs match the changed files, and only + those. + +The code is the source of truth for the details; its module docstring is worth +reading before changing it. + +## Why a check was skipped + +The run page's summary pane answers this without opening a log: one row per +suite, with its verdict, the rule that decided it, and up to three of the +changed files that matched. + +The step log of the `CI Scope` job has the same information in text form. It +starts with `CI scope: mode=… full=…` (`grep '^CI scope'` finds it), then one +line per suite — `run` or `skip`, source, reason — then `gates:` with the exact +values the jobs read. + +A degraded decision is never silent. A failed label or file lookup emits +`::warning::` and widens the gate to every suite. + +## Forcing a full run + +Add the `ci:full` label to the pull request and click "Re-run all jobs". The +label is read through the API at run time, not from the event payload, so one +added after the first run still counts. `/ci` dispatch +(`.github/scripts/ci-slash.py`), the `CI_SCOPE_FORCE_ALL` repository variable +and every merge-queue run also run everything: the classifier returns a forced +plan there, so a wrong skip is still exercised before the change lands. None of +the gated jobs is a required check, so a failure in the queue does not block the +merge by itself. + +`CI_SCOPE_FORCE_ALL` is the switch to flip while debugging the rules themselves. + +## Changing the rules + +`scope-rules.toml` is data, not code: `ci_scope_rules.py` loads it with the +standard library's `tomllib`. Two things are validated: + +- the shape — an unknown or cyclic `@group` reference, a `gate` that disagrees + with the suite id, a gate missing from `[gates]`, or an exemption without a + `reason` all raise; +- that every glob still points at something that exists, so a renamed or + deleted crate fails the check instead of silently matching nothing. + +Run both with `make check-ci-scope`, or the path half alone with +`python3 .github/ci/ci_scope_rules.py --check-paths`. The same command runs in +the `CI Scope Rules` job of `checks.yml`, on `ubuntu-slim` — the image the +classifier itself uses, so a path that exists there also exists when the verdict +is computed. + +A malformed file fails the classify step, and because every step of that job is +`continue-on-error`, every gated job runs instead. + +`[[matching.exempt]]` lists paths outside the gate's remit: they select no suite +and never widen the gate. Each one carries a `reason`, and the loader rejects an +empty one — what CI never runs is the part a reader must be able to justify. + +`tests/fixtures/scope-cases.toml` is test data, not configuration: a diff shape +and the suites it must start. `ScopeCaseTests` replays each case through the +classifier, so a rule edit that changes behaviour fails with the case name +instead of silently rerouting CI. The cases carry no pull request numbers; +`git log` has those. + +## Adding a suite + +1. Add a `[suites."/"]` table to `scope-rules.toml` with `gate`, + `paths` (globs or `@group` references) and a one-line `description`. The id + is `/`, and `gate` is that id with `-` replaced by `_`; + the loader and `test_suite_metadata` both enforce it. +2. Add the matching matrix leg to the job in `integration.yml`. +3. Gate the job's `if:` with the matching output, and list `scope` in its + `needs`. A new gate also needs a key in `derive_outputs` and in the `scope` + job's `outputs`; `test_every_gate_routes_to_an_output` fails when that + routing is missing. +4. Add a case to `tests/fixtures/scope-cases.toml` and run + `python3 .github/ci/tests/test_ci_scope.py`. `CrateCoverageTests` separately + fails when a workspace crate matches no suite and is not excused in + `UNMAPPED_CRATES`: the runtime net would run everything, but the rule table + is what should be updated. + +## Ground rules for the workflow + +These six rules keep the gate failing open. Break one and either CI runs jobs it +should have skipped, or a classifier failure takes CI down with it. + +- **Read the output as `!= 'false'`.** An absent output means "run"; writing + `== 'true'` inverts the failure direction. +- **Start every gate with `!cancelled()`.** Without a status function the + condition carries an implicit `success()` over `needs`, so a `scope` job that + times out would skip the gates. Other dependencies are therefore required + explicitly with `needs..result == 'success'`. +- **Every fallible step of `scope` is `continue-on-error: true`.** GitHub skips + the dependents of a *failed* job, so a classifier crash must leave the job + green with no outputs rather than take CI down with it. +- **Rules come from the base revision.** The `scope` job checks out + `pull_request.base.sha` (or `merge_group.base_sha`), so a pull request cannot + weaken its own gate. On the first pull request that introduces this directory + the base has no `ci_scope.py`: the step fails, no outputs are written, and + every gate runs. +- **One gate per job, never per leg.** GitHub does not expose `matrix` to + `jobs..if`. Per-suite verdicts stay visible in the log. +- **Pass the run's `head`/`base` SHAs and let the classifier compare them with + the pull request's live revisions.** A re-run keeps the original SHAs while + GitHub serves the *current* file list; without the comparison the new diff + would be judged against the old tree. With it, a mismatch runs everything. diff --git a/.github/ci/ci_scope.py b/.github/ci/ci_scope.py new file mode 100755 index 00000000000..571e975325a --- /dev/null +++ b/.github/ci/ci_scope.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Decide which integration CI suites must run for one pull request. + +Pipeline, in priority order: + +1. forced mode -- `merge_group`, `schedule`, `workflow_dispatch`, + `CI_SCOPE_FORCE_ALL`, or the `ci:full` label. Every suite runs. +2. always-run floor -- a changed file matches `ALWAYS_RUN`. Every suite runs. +3. uncovered build change -- a path under `SRC_TREE` that no suite claims, so a + crate added or renamed later still runs everything. +4. static path matches -- glob match of the changed files against the suite + catalog in `scope-rules.toml`. Paths under `EXEMPT_TREES` are outside the + gate's remit and are dropped first: `@server` claims `src/query/**`, which + would otherwise put a PromQL-only change through the whole fleet. + +The verdict is deterministic: the same diff always produces the same plan, and +a change that no rule explains falls through to "run everything". + +The plan maps to `$GITHUB_OUTPUT` keys that the `integration.yml` gate jobs +consume; `--dry-run` prints them as JSON. +""" + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request + +from ci_scope_rules import ( + ALWAYS_RUN, + EXEMPT_TREES, + SRC_TREE, + SUITES, + suite_group, + suite_paths, +) + +MAX_FILES = 300 +HTTP_TIMEOUT = 45 +FORCED_EVENTS = {"merge_group", "schedule", "workflow_dispatch"} +# A diff with no code, config or workflow file never needs a suite. +CODE_FILE = re.compile( + r"\.(rs|toml|lock|sh|ya?ml)$|(^|/)Makefile$|(^|/)Cargo\.toml$|\.sql$" +) +FUZZ_GATES = ("fuzz_standalone", "fuzz_distributed", "fuzz_chaos") + + +class ScopeError(Exception): + """A handled failure: the caller falls back instead of crashing.""" + + +# --------------------------------------------------------------------------- +# HTTP seam (tests replace `_request`) +# --------------------------------------------------------------------------- + + +def _request(method, url, headers, body=None, timeout=HTTP_TIMEOUT): + request = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.read() + except urllib.error.HTTPError as err: + return err.code, err.read() + except (urllib.error.URLError, TimeoutError, OSError) as err: + raise ScopeError(f"{method} {url}: {err}") from err + + +def _get(url, headers, timeout=HTTP_TIMEOUT): + return _request("GET", url, headers, None, timeout) + + +def _post(url, headers, body, timeout=HTTP_TIMEOUT): + return _request("POST", url, headers, body, timeout) + + +def get_json(url, headers, timeout=HTTP_TIMEOUT): + """GET returning (status, parsed json); transport failures raise `ScopeError`.""" + status, raw = _get(url, headers, timeout) + try: + payload = json.loads(raw.decode("utf-8", "replace")) if raw else None + except json.JSONDecodeError: + payload = None + return status, payload + + +# --------------------------------------------------------------------------- +# Path matching +# --------------------------------------------------------------------------- + + +def glob_to_regex(pattern): + """Translate a repo glob into an anchored regexp. + + `**/` also matches zero directories, `**` spans `/`, `*` and `?` do not. + """ + out = ["^"] + index = 0 + while index < len(pattern): + char = pattern[index] + if char == "*": + end = index + while end < len(pattern) and pattern[end] == "*": + end += 1 + if end - index >= 2: + if end < len(pattern) and pattern[end] == "/": + out.append("(?:.*/)?") + index = end + 1 + continue + out.append(".*") + else: + out.append("[^/]*") + index = end + elif char == "?": + out.append("[^/]") + index += 1 + else: + out.append(re.escape(char)) + index += 1 + out.append("$") + return re.compile("".join(out)) + + +def compile_patterns(patterns): + return [glob_to_regex(pattern) for pattern in patterns] + + +def matches_any(filename, compiled): + return any(regex.match(filename) for regex in compiled) + + +# --------------------------------------------------------------------------- +# GitHub API +# --------------------------------------------------------------------------- + + +def github_api(method, path, token, payload=None): + base = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") + url = base + path + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "greptimedb-ci-scope", + } + if token: + headers["Authorization"] = "Bearer " + token + body = None + if payload is not None: + body = json.dumps(payload).encode() + headers["Content-Type"] = "application/json" + if method == "GET": + return get_json(url, headers) + status, raw = _request(method, url, headers, body, HTTP_TIMEOUT) + try: + parsed = json.loads(raw.decode("utf-8", "replace")) if raw else None + except json.JSONDecodeError: + parsed = None + return status, parsed + + +def fetch_changed_files(repo, number, token): + """Return (files, truncated). `truncated` means the PR has more than MAX_FILES.""" + files = [] + page = 1 + while True: + status, payload = github_api( + "GET", f"/repos/{repo}/pulls/{number}/files?per_page=100&page={page}", token + ) + if status != 200 or not isinstance(payload, list): + raise ScopeError(f"cannot list pull request files (HTTP {status})") + for item in payload: + if not isinstance(item, dict): + continue + patch = item.get("patch") + files.append( + { + "filename": item.get("filename", ""), + "previous_filename": item.get("previous_filename") or "", + "status": item.get("status", "modified"), + "additions": item.get("additions", 0), + "deletions": item.get("deletions", 0), + "patch": patch if isinstance(patch, str) else "", + "patch_included": isinstance(patch, str) and bool(patch), + } + ) + if len(payload) < 100: + return files, False + if len(files) >= MAX_FILES: + # Keep `per_page=100`: with `per_page=1`, page N is item N, so the + # probe would answer "is there a file 4?" instead of "file 301?". + status, extra = github_api( + "GET", + f"/repos/{repo}/pulls/{number}/files?per_page=100&page={page + 1}", + token, + ) + if status != 200 or not isinstance(extra, list): + raise ScopeError( + "cannot confirm the changed file list is complete " + f"(HTTP {status})" + ) + del files[MAX_FILES:] + return files, bool(extra) + page += 1 + + +def fetch_pull(repo, number, token): + """Return (labels, head sha, base sha) for one pull request.""" + status, payload = github_api("GET", f"/repos/{repo}/pulls/{number}", token) + if status != 200 or not isinstance(payload, dict): + raise ScopeError(f"cannot read pull request (HTTP {status})") + labels = payload.get("labels") or [] + head = ((payload.get("head") or {}).get("sha") or "").strip() + base = ((payload.get("base") or {}).get("sha") or "").strip() + names = {label.get("name", "") for label in labels if isinstance(label, dict)} + return names, head, base + + +def revision_drift(args, head, base): + """Return a note when the tested revisions are no longer the live ones. + + A re-run keeps the run's original SHAs while the file list served by GitHub + is the pull request's current one. After a force-push the classifier would + judge the new diff for the old tree, so a mismatch runs every suite instead. + """ + for supplied, live, name in ( + (args.head_sha, head, "head"), + (args.base_sha, base, "base"), + ): + if supplied and live and supplied.lower() != live.lower(): + return ( + f"pull request {name} moved from {supplied[:12]} to " + f"{live[:12]} after this run started" + ) + return "" + + +# --------------------------------------------------------------------------- +# Decisions +# --------------------------------------------------------------------------- + + +def file_paths(item): + """Every path the change touched; a rename keeps the deleted side too.""" + names = [item["filename"]] + previous = item.get("previous_filename") + if previous and previous != item["filename"]: + names.append(previous) + return names + + +def gated_paths(item): + """The paths of one changed file that the gate judges. + + A path under `EXEMPT_TREES` is outside the remit: it selects no suite, and + it is not a change that "no rule covers", so it cannot widen the gate. + """ + return [name for name in file_paths(item) if not name.startswith(EXEMPT_TREES)] + + +def path_matches(files): + """Return (always_run_hits, {suite_id: [matching file paths]}).""" + always_compiled = compile_patterns(ALWAYS_RUN) + suite_matchers = { + suite_id: compile_patterns(suite_paths(suite)) + for suite_id, suite in SUITES.items() + } + always_hits = set() + matches = {suite_id: set() for suite_id in SUITES} + for item in files: + for name in file_paths(item): + if matches_any(name, always_compiled): + always_hits.add(name) + for name in gated_paths(item): + for suite_id, compiled in suite_matchers.items(): + if matches_any(name, compiled): + matches[suite_id].add(name) + return sorted(always_hits), { + suite_id: sorted(matched) for suite_id, matched in matches.items() + } + + +def uncovered_changes(files, matches): + """True when a change can reach code that no suite path claims. + + This is the net for a crate that was added, split or renamed after the rules + were written: without it such a change matches no suite and would skip every + gated job. A single unmatched path is enough, even when other paths in the + same diff match a suite. + """ + return first_uncovered(files, matches) is not None + + +def first_uncovered(files, matches): + """The first changed path that no suite claims, or None. + + Naming it is what makes the widening explainable in the log. + """ + matched = {name for names in matches.values() for name in names} + return next( + ( + name + for item in files + for name in gated_paths(item) + if name.startswith(SRC_TREE) and name not in matched + ), + None, + ) + + +def has_meaningful_changes(files): + return any(CODE_FILE.search(item["filename"]) for item in files) + + +def finalize(plan): + """`full` means every gated suite runs, whatever produced the decision.""" + plan["full"] = all(entry["run"] for entry in plan["suites"].values()) + return plan + + +def all_suites(run, reason, source): + return { + suite_id: {"run": run, "source": source, "reason": reason} + for suite_id in SUITES + } + + +def full_plan(plan, reason, source, mode="static"): + """Every suite runs; `finalize` derives `full` from the verdicts.""" + plan.update(mode=mode, suites=all_suites(True, reason, source)) + return finalize(plan) + + +def forced_reason(args, labels): + """Return why every suite must run, or an empty string.""" + event = args.event or os.environ.get("GITHUB_EVENT_NAME") or "pull_request" + if event in FORCED_EVENTS: + return f"event={event}" + if os.environ.get("CI_SCOPE_FORCE_ALL", "").strip().lower() in { + "1", + "true", + "yes", + "on", + }: + return "CI_SCOPE_FORCE_ALL" + if "ci:full" in labels: + return "label ci:full" + return "" + + +def build_plan(args, token): + notes = [] + plan = { + "mode": "static", + "head_sha": args.head_sha or "", + "base_sha": args.base_sha or "", + "full": False, + "suites": {}, + "matching": {}, + "notes": notes, + } + + # Events and variables that force a run need no GitHub call at all; the + # `ci:full` label is the only reason to read the pull request early. + forced = forced_reason(args, set()) + labels, head_live, base_live = set(), "", "" + if args.pr and not forced: + try: + labels, head_live, base_live = fetch_pull( + args.repo, args.pr, token + ) + except ScopeError as err: + # A failed lookup must widen the gate, never narrow it. + notes.append(f"pull request lookup failed: {err}") + return full_plan(plan, "pull request lookup failed", "always-run") + forced = forced_reason(args, labels) + + if forced: + notes.append(f"forced: {forced}") + return full_plan(plan, f"forced by {forced}", "forced", mode="forced") + + if not args.pr: + raise ScopeError("--pr is required unless the event forces every suite") + + drift = revision_drift(args, head_live, base_live) + if drift: + notes.append(drift) + return full_plan(plan, drift, "always-run") + + files, truncated = fetch_changed_files(args.repo, args.pr, token) + if truncated: + notes.append(f"changed file list truncated at {MAX_FILES}") + return full_plan( + plan, + f"pull request has more than {MAX_FILES} changed files", + "always-run", + ) + + always_hits, matches = path_matches(files) + # Keep the evidence: the step summary explains a verdict by naming the files + # that matched, which is what triage asks for first. + plan["matching"] = {suite_id: sorted(names) for suite_id, names in matches.items()} + if always_hits: + notes.append(f"always-run: {', '.join(sorted(always_hits)[:5])}") + return full_plan( + plan, + "always-run path: " + ", ".join(sorted(always_hits)[:3]), + "always-run", + ) + + first = first_uncovered(files, matches) + if first is not None: + notes.append(f"uncovered build change: {first}") + return full_plan(plan, f"uncovered build change: {first}", "uncovered") + + static_run = {suite_id: bool(matched) for suite_id, matched in matches.items()} + + if not has_meaningful_changes(files) and not any(static_run.values()): + plan.update( + mode="static", + full=False, + suites={ + suite_id: { + "run": False, + "source": "static", + "reason": "no code, config or workflow file changed", + } + for suite_id in SUITES + }, + ) + notes.append("no meaningful changes: skip every suite") + return finalize(plan) + + plan.update( + mode="static", + suites={ + suite_id: { + "run": static_run[suite_id], + "source": "static", + "reason": ("path rule: " + ", ".join(matches[suite_id][:3])) + if matches[suite_id] + else "no path rule matched", + } + for suite_id in SUITES + }, + ) + return finalize(plan) + + +def derive_outputs(plan): + """Flatten the plan into the `$GITHUB_OUTPUT` keys the workflow gates on. + + + Every gate in `SUITES` must route to a key here, otherwise its suites are + classified but never switch a job; `test_every_gate_routes_to_an_output` + asks this function directly, so a new gate fails there until it is routed. + + + Job-level `if:` cannot read the `matrix` context, so the workflow gates a + whole fuzz gate instead of individual matrix legs; the per-suite verdicts + stay visible in the step log. + """ + + def suites_of(gate): + return [ + suite_id + for suite_id, entry in plan["suites"].items() + if entry["run"] and SUITES[suite_id]["gate"] == gate + ] + + outputs = {} + legs = {} + for gate in FUZZ_GATES: + running = suites_of(gate) + legs[gate] = [suite_group(suite_id) for suite_id in running] + legs[gate] = [leg for leg in legs[gate] if leg] + outputs[gate] = "true" if running else "false" + unstable = bool(suites_of("fuzz_unstable")) + # Every gate outside the fuzz group switches exactly one job of its own. + for gate in sorted({suite["gate"] for suite in SUITES.values()}): + if gate not in FUZZ_GATES: + outputs[gate] = "true" if suites_of(gate) else "false" + outputs["any_fuzz"] = ( + "true" + if unstable or any(outputs[gate] == "true" for gate in FUZZ_GATES) + else "false" + ) + distributed = outputs["fuzz_distributed"] == "true" or outputs["fuzz_chaos"] == "true" + outputs["build_ci_needed"] = "true" if unstable or distributed else "false" + return outputs, legs + + +def render_log(plan): + """One line per suite plus the emitted gates, for the step log. + + Triage starts in the run log, so the verdict has to be readable there + without any other artifact. + """ + head = f"CI scope: mode={plan['mode']} full={'true' if plan['full'] else 'false'}" + lines = [head] + width = max((len(suite_id) for suite_id in plan["suites"]), default=0) + for suite_id, entry in plan["suites"].items(): + lines.append( + f" {'run ' if entry['run'] else 'skip'} {suite_id:<{width}} " + f"{entry['source']:<12} {entry['reason']}" + ) + outputs, _ = derive_outputs(plan) + lines.append( + " gates: " + " ".join(f"{key}={value}" for key, value in outputs.items()) + ) + for note in plan["notes"]: + lines.append(f" note: {note}") + return "\n".join(lines) + + +SUMMARY_MAX_FILES = 3 + + +def render_summary(plan): + """Markdown for the job summary: the matching evidence behind each verdict. + + The step log answers "what ran"; this answers "why", which is the question + when a pull request unexpectedly skips or starts a suite. + """ + lines = [ + f"### CI scope: mode={plan['mode']}, full={'true' if plan['full'] else 'false'}", + "", + "| suite | verdict | rule | matched files |", + "| --- | --- | --- | --- |", + ] + matching = plan.get("matching") or {} + for suite_id, entry in plan["suites"].items(): + matched = matching.get(suite_id) or [] + shown = ", ".join(f"`{name}`" for name in matched[:SUMMARY_MAX_FILES]) + if len(matched) > SUMMARY_MAX_FILES: + shown += f" +{len(matched) - SUMMARY_MAX_FILES} more" + lines.append( + f"| `{suite_id}` | {'run' if entry['run'] else 'skip'} | " + f"{entry['source']} | {shown or '—'} |" + ) + if plan["notes"]: + lines += ["", "
notes", ""] + lines += [f"- {note}" for note in plan["notes"]] + lines += ["", "
"] + return "\n".join(lines) + + +def log_annotations(plan): + """Workflow-run annotations for every degraded decision. + + A lookup failure widens the gate; it must be visible in the run list without + opening the job log, otherwise it looks like a confident verdict. + """ + annotations = [] + for note in plan["notes"]: + if note.startswith("pull request lookup failed"): + suffix = "every suite runs" + level = "warning" + else: + continue + message = f"CI scope: {note}; {suffix}"[:300] + annotations.append(f"::{level}::{message}") + return annotations + + +def emit(plan, args): + outputs, _ = derive_outputs(plan) + if args.dry_run: + print(json.dumps(outputs, indent=2)) + elif args.github_output: + with open(args.github_output, "a", encoding="utf-8") as handle: + for key, value in outputs.items(): + handle.write(f"{key}={value}\n") + if args.summary: + with open(args.summary, "a", encoding="utf-8") as handle: + handle.write(render_summary(plan) + "\n") + + +def parse_args(argv): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "")) + parser.add_argument("--pr", type=int) + parser.add_argument("--head-sha", default="") + parser.add_argument("--base-sha", default="") + parser.add_argument( + "--event", + choices=["pull_request", "merge_group", "schedule", "workflow_dispatch"], + ) + parser.add_argument("--github-output") + parser.add_argument( + "--summary", + help="append the markdown matching table to this file ($GITHUB_STEP_SUMMARY)", + ) + parser.add_argument("--dry-run", action="store_true") + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or "" + plan = build_plan(args, token) + for annotation in log_annotations(plan): + print(annotation) + print(render_log(plan)) + emit(plan, args) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/ci/ci_scope_rules.py b/.github/ci/ci_scope_rules.py new file mode 100755 index 00000000000..c7453424848 --- /dev/null +++ b/.github/ci/ci_scope_rules.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Policy for the integration CI admission gate. + +The rules are data and live in `.github/ci/scope-rules.toml` so they can be +reviewed and changed without touching the classifier. This module parses that +file once at import, validates it, and exposes the names `ci_scope.py` uses. + +A malformed file raises at import: the classifier job's steps are +`continue-on-error`, so a broken rules file leaves the gate outputs empty and +every gated job runs. +""" + +import argparse +import pathlib +import sys +import tomllib + +RULES_PATH = pathlib.Path(__file__).resolve().with_name("scope-rules.toml") +# `.github/ci/scope-rules.toml`: parents[0] is `.github/ci`, parents[2] the repo. +REPO_ROOT = RULES_PATH.parents[2] + + + +class RulesError(ValueError): + """The rules file is missing, malformed or internally inconsistent.""" + + +def _require(table, key, where): + if key not in table: + raise RulesError(f"{where} is missing `{key}`") + return table[key] + + +def _strings(value, where): + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise RulesError(f"{where} must be an array of strings") + return list(value) + + +def _trees(entries, where): + """Normalise `[[matching.*_tree]]` tables into bare path prefixes. + + Each entry carries a `reason`: the exclusions decide what CI never runs, so + the justification is part of the contract, not a comment. + """ + if not isinstance(entries, list): + raise RulesError(f"{where} must be an array of tables") + paths = [] + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise RulesError(f"{where}[{index}] is not a table") + path = _require(entry, "path", f"{where}[{index}]") + if not isinstance(path, str) or not path: + raise RulesError(f"{where}[{index}].path must be a non-empty string") + reason = _require(entry, "reason", f"{where}[{index}]") + if not isinstance(reason, str) or not reason.strip(): + raise RulesError(f"{where}[{index}].reason must explain the exclusion") + paths.append(path) + return paths + + +def validate(data): + """Check the shape of a parsed rules document and return it normalised.""" + if not isinstance(data, dict): + raise RulesError("rules document is not a table") + matching = _require(data, "matching", "the rules file") + gates_in = _require(data, "gates", "the rules file") + groups_in = _require(data, "groups", "the rules file") + suites_in = _require(data, "suites", "the rules file") + + groups = {} + for name, paths in groups_in.items(): + groups[name] = _strings(paths, f"groups.{name}") + + def expand(entries, stack=()): + out = [] + for entry in entries: + if not entry.startswith("@"): + out.append(entry) + continue + name = entry[1:] + if name not in groups: + raise RulesError(f"unknown path group `{entry}`") + if name in stack: + raise RulesError(f"path group cycle: {' -> '.join(stack + (name,))}") + out.extend(expand(groups[name], stack + (name,))) + return out + + for name in groups: + expand([f"@{name}"]) # every group must resolve on its own + + suites = {} + for suite_id, suite in suites_in.items(): + if not isinstance(suite, dict): + raise RulesError(f"suites.{suite_id} is not a table") + gate = _require(suite, "gate", f"suites.{suite_id}") + paths = _strings( + _require(suite, "paths", f"suites.{suite_id}"), f"suites.{suite_id}.paths" + ) + description = _require(suite, "description", f"suites.{suite_id}") + if not isinstance(description, str) or not description.strip(): + raise RulesError(f"suites.{suite_id}.description is empty") + # `gate/leg` for a matrix job, or a bare gate for a single-leg job. + prefix = suite_id.split("/", 1)[0].replace("-", "_") + if prefix != gate: + raise RulesError( + f"suites.{suite_id}.gate is `{gate}`, expected `{prefix}`" + ) + expand(paths) + suites[suite_id] = { + "gate": gate, + "paths": paths, + "description": description, + } + + gate_jobs = {} + for gate, job in gates_in.items(): + if not isinstance(job, str) or not job: + raise RulesError(f"gates.{gate} must name a job id") + gate_jobs[gate] = job + used = {suite["gate"] for suite in suites.values()} + unknown = sorted(used - set(gate_jobs)) + if unknown: + raise RulesError(f"suites use gates missing from [gates]: {', '.join(unknown)}") + dead = sorted(set(gate_jobs) - used) + if dead: + raise RulesError(f"[gates] names gates no suite uses: {', '.join(dead)}") + + return { + "always_run": _strings( + _require(matching, "always_run", "matching"), "matching.always_run" + ), + "src_tree": _require(matching, "src_tree", "matching"), + "exempt_trees": _trees( + _require(matching, "exempt", "matching"), "matching.exempt" + ), + "gates": gate_jobs, + "groups": groups, + "suites": suites, + } + + +def load(path=None): + """Parse and validate the rules file.""" + path = RULES_PATH if path is None else path + try: + raw = pathlib.Path(path).read_bytes() + except OSError as err: + raise RulesError(f"cannot read {path}: {err}") from err + try: + data = tomllib.loads(raw.decode("utf-8")) + except (tomllib.TOMLDecodeError, UnicodeDecodeError) as err: + raise RulesError(f"{path} is not valid TOML: {err}") from err + return validate(data) + + +_RULES = load() + +ALWAYS_RUN = _RULES["always_run"] +SRC_TREE = _RULES["src_tree"] +EXEMPT_TREES = tuple(_RULES["exempt_trees"]) +GATE_JOBS = _RULES["gates"] +PATH_GROUPS = _RULES["groups"] +SUITES = _RULES["suites"] + + +def main(argv=None): + """Validate the rules file and print what it holds; non-zero on failure.""" + parser = argparse.ArgumentParser(description="Validate the CI scope rules.") + parser.add_argument( + "--check-paths", + action="store_true", + help="also fail when a pattern's literal prefix does not exist", + ) + args = parser.parse_args(argv) + try: + rules = load() + except RulesError as err: + print(f"scope rules invalid: {err}", file=sys.stderr) + return 1 + if args.check_paths: + missing = missing_paths(rules) + for pattern, prefix in missing: + print(f"missing path: {pattern} (nothing under {prefix})", file=sys.stderr) + return 1 if missing else 0 + print( + f"scope rules ok: {len(rules['suites'])} suites, " + f"{len(rules['groups'])} path groups, " + f"{len(rules['always_run'])} always-run entries" + ) + return 0 + + +def suite_group(suite_id): + """Return the matrix group id encoded in a suite id, or None.""" + _, _, group = suite_id.partition("/") + return group or None + + +def expand_paths(entries, groups=None): + """Expand `@group` references, including groups nested in other groups.""" + groups = PATH_GROUPS if groups is None else groups + patterns = [] + for entry in entries: + if entry.startswith("@"): + patterns.extend(expand_paths(groups[entry[1:]], groups)) + else: + patterns.append(entry) + return patterns + + +def suite_paths(suite): + """Expand `@group` references in one suite definition into globs.""" + return expand_paths(suite["paths"]) + + +def _literal_prefix(pattern): + """Return the longest leading path that the glob cannot vary. + + Everything from the first `*`, `?` or `[` onwards is variable. When that + wildcard sits inside a segment the incomplete segment is dropped, so + `src/a/foo*.rs` yields `src/a/`; a wildcard in the very first segment (for + example `*.md`) yields `""` because the whole path is variable. + """ + cuts = [pattern.find(ch) for ch in "*?[" if ch in pattern] + if not cuts: + return pattern + prefix = pattern[: min(cuts)] + if "/" not in prefix: + return "" + return prefix.rsplit("/", 1)[0] + "/" + + +def _all_patterns(rules): + """Every glob the rules mention, with `@group` references expanded.""" + patterns = [] + for key in ("always_run", "exempt_trees"): + patterns.extend(expand_paths(rules[key], rules["groups"])) + for suite in rules["suites"].values(): + patterns.extend(expand_paths(suite["paths"], rules["groups"])) + return patterns + + +def missing_paths(rules=None, root=None): + """Return `(pattern, prefix)` for patterns whose literal prefix is absent. + + A rule that names a directory nobody renamed would otherwise match nothing + forever, silently narrowing the gate. A pattern with no fixed prefix is + skipped: there is nothing on disk to check. + """ + rules = _RULES if rules is None else rules + root = pathlib.Path(REPO_ROOT if root is None else root) + missing = set() + for pattern in _all_patterns(rules): + prefix = _literal_prefix(pattern) + if prefix and not (root / prefix).exists(): + missing.add((pattern, prefix)) + return sorted(missing) + + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/ci/scope-rules.toml b/.github/ci/scope-rules.toml new file mode 100644 index 00000000000..690a398db2e --- /dev/null +++ b/.github/ci/scope-rules.toml @@ -0,0 +1,281 @@ +# Rules for the integration CI admission gate. +# +# Data only: `ci_scope_rules.py` loads this file with the stdlib `tomllib`, and +# `ci_scope.py` matches diffs against it. Edit this file, not the code, when a +# suite's paths change. +# +# Conventions +# ----------- +# - `**` crosses directories, `*` does not; a trailing `/**` covers everything +# under a directory. +# - `@name` inside `suites.*.paths` expands `[groups].name`, and groups may +# reference other groups. Expansion is recursive. +# - A suite id is `/`; `gate` is the `$GITHUB_OUTPUT` +# key that switches the job, and it must equal the id prefix with `-` +# replaced by `_`. +# - Everything here is a floor, never a filter: an unmatched `src/` path runs +# every suite, and a malformed file fails the classifier job, whose steps are +# `continue-on-error`, so the gated jobs run. + +[matching] +# A changed file matching one of these runs every suite. +# Put here anything that changes what the suites build or how they start. +always_run = [ + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + ".cargo/**", + "tests-fuzz/**", + "tests-integration/**", + "config/**", + "docker/**", + ".github/ci/**", + ".github/workflows/integration.yml", + ".github/actions/**", + # These helpers run through composite actions and affect test execution. + ".github/scripts/kind-with-registry.sh", + ".github/scripts/run-fuzz-targets.sh", + ".github/scripts/collect-fuzz-target-artifacts.sh", + ".github/scripts/wait-kafka-ready.sh", + ".github/scripts/pull-test-deps-images.sh", +] + +# A change under `src/` that no suite claims runs every suite: that is the net +# for a crate added, split or renamed after these rules were written. +src_tree = "src/" + +# Paths outside the gate's remit. They select no suite, and they do not count as +# "no rule covers this" either, so they never widen the gate. Each needs a +# `reason`: what CI covers them instead. +[[matching.exempt]] +path = "src/promql/" +reason = "PromQL crate: rust.yml and the promql sqlness cases own it; the fuzz suites speak SQL." + +[[matching.exempt]] +path = "src/query/src/promql/" +reason = "PromQL module inside `src/query`, which `@server` claims as `src/query/**`." + +[[matching.exempt]] +path = "src/flow/" +reason = "Flow engine: rust.yml and the flow sqlness cases own it; no fuzz suite loads it." + +# Which job each gate switches. The keys are `$GITHUB_OUTPUT` names; the values +# are job ids in `integration.yml`. The classifier cannot read YAML, so the +# mapping is data here, and a test proves both directions line up. +[gates] +fuzz_standalone = "fuzztest" +fuzz_unstable = "unstable-fuzztest" +fuzz_distributed = "distributed-fuzztest" +fuzz_chaos = "distributed-fuzztest-with-chaos" +export_import_v2 = "export-import-v2-e2e" +sqlness_kvbackend = "sqlness-kvbackend" + +[groups] +server = [ + "src/operator/**", + "src/sql/**", + "src/catalog/**", + "src/datatypes/**", + "src/table/**", + "src/store-api/**", + "src/session/**", + "src/api/**", + "src/servers/**", + "src/frontend/**", + # Also covers `src/query/src/promql/`, which is exempt (see + # `[[matching.exempt]]`): a PromQL-only change must not run the fuzz fleet. + "src/query/**", + "src/partition/**", + "src/index/**", + "src/object-store/**", + "src/cache/**", + "src/puffin/**", + "src/auth/**", + "src/client/**", + "src/log-query/**", + "src/pipeline/**", + "src/plugins/**", + "src/cmd/**", + "src/standalone/**", + "src/datanode/**", + "src/common/base/**", + "src/common/batcher/**", + "src/common/catalog/**", + "src/common/config/**", + "src/common/datasource/**", + "src/common/decimal/**", + "src/common/error/**", + "src/common/event-recorder/**", + "src/common/frontend/**", + "src/common/function/**", + "src/common/grpc/**", + "src/common/grpc-expr/**", + "src/common/greptimedb-telemetry/**", + "src/common/macro/**", + "src/common/mem-prof/**", + "src/common/memory-manager/**", + "src/common/options/**", + "src/common/plugins/**", + "src/common/pprof/**", + "src/common/query/**", + "src/common/recordbatch/**", + "src/common/runtime/**", + "src/common/session/**", + "src/common/sql/**", + "src/common/stat/**", + "src/common/substrait/**", + "src/common/telemetry/**", + "src/common/test-util/**", + "src/common/time/**", + "src/common/version/**", + "src/common/workload/**", +] + +# Storage engines and WAL. +engine = [ + "src/mito2/**", + "src/mito-codec/**", + "src/metric-engine/**", + "src/file-engine/**", + "src/log-store/**", + "src/common/wal/**", +] + +# DDL and procedure code standalone executes in-process: it builds a +# `DdlManager` and runs `LocalProcedureExecutor` without any metasrv. +meta-core = [ + "src/common/meta/**", + "src/common/procedure/**", + "src/common/procedure-test/**", +] + +# Everything that sits on the KV backend the two sqlness KvBackend legs +# exercise: the backend implementations, the metadata service and the startup +# wiring that picks a backend. +kv-backend = [ + # The runner implements `--setup-pg`/`--setup-mysql`, which only these legs pass. + "tests/runner/**", + "src/common/meta/**", + "src/meta-srv/**", + "src/meta-client/**", + "src/common/procedure/**", + "src/cmd/**", + "src/common/config/**", + "src/common/options/**", +] + +# Adds the metasrv/meta-client crates on top of the in-process DDL code. +meta = [ + "src/meta-srv/**", + "src/meta-client/**", + "@meta-core", +] + +[suites."fuzz-standalone/database-and-regular-table"] +gate = "fuzz_standalone" +paths = [ + "@server", + "@engine", + "@meta-core", +] +description = "Standalone-server black-box fuzz of CREATE DATABASE/TABLE, ALTER, INSERT over MySQL." + +[suites."fuzz-standalone/logical-table"] +gate = "fuzz_standalone" +paths = [ + "@server", + "@engine", + "@meta-core", +] +description = "Standalone logical-table DDL/INSERT fuzz (mito2 logical tables)." + +[suites."fuzz-unstable/standalone"] +gate = "fuzz_unstable" +paths = [ + "@server", + "@engine", + "@meta-core", +] +description = "Unstable standalone create-table fuzz (nix build feature)." + +[suites."fuzz-distributed/database-and-regular-table"] +gate = "fuzz_distributed" +paths = [ + "@server", + "@engine", + "@meta", +] +description = "Distributed (frontend/datanode/metasrv, remote WAL) DDL/DML fuzz." + +[suites."fuzz-distributed/logical-table"] +gate = "fuzz_distributed" +paths = [ + "@server", + "@engine", + "@meta", +] +description = "Distributed logical-table fuzz." + +[suites."fuzz-distributed/repartition"] +gate = "fuzz_distributed" +paths = [ + "@server", + "@engine", + "@meta", +] +description = "Distributed repartition ALTER TABLE + GC fuzz." + +[suites."fuzz-distributed/logical-pruning"] +gate = "fuzz_distributed" +paths = [ + "@engine", + "@meta", +] +description = "Remote-WAL logical table pruning of stale manifests." + +[suites."fuzz-chaos/migration"] +gate = "fuzz_chaos" +paths = [ + "@server", + "@engine", + "@meta", +] +description = "Region migration under Chaos Mesh, mito and metric engines." + +[suites."fuzz-chaos/failover"] +gate = "fuzz_chaos" +paths = [ + "@server", + "@engine", + "@meta", +] +description = "Region failover under Chaos Mesh, mito and metric engines." + +[suites."fuzz-chaos/repartition"] +gate = "fuzz_chaos" +paths = [ + "@server", + "@engine", + "@meta", +] +description = "Repartition + GC under Chaos Mesh." + +[suites."export-import-v2"] +gate = "export_import_v2" +paths = [ + "src/cli/**", + "@server", + "@engine", + "@meta-core", +] +description = "CLI COPY DATABASE export/import v2 end-to-end against S3." + +[suites."sqlness-kvbackend/metasrv-postgres"] +gate = "sqlness_kvbackend" +paths = ["@kv-backend"] +description = "Sqlness with PostgreSQL as the metadata KV backend." + +[suites."sqlness-kvbackend/metasrv-mysql"] +gate = "sqlness_kvbackend" +paths = ["@kv-backend"] +description = "Sqlness with MySQL as the metadata KV backend." diff --git a/.github/ci/tests/fixtures/scope-cases.toml b/.github/ci/tests/fixtures/scope-cases.toml new file mode 100644 index 00000000000..3b52024a956 --- /dev/null +++ b/.github/ci/tests/fixtures/scope-cases.toml @@ -0,0 +1,158 @@ +# Golden cases for the admission gate. +# +# `tests/test_ci_scope.py` replays every case through the real classifier, +# so an edit to `scope-rules.toml` that changes behaviour fails with the case +# name here. The cases encode intent, not implementation: each one says which +# suites a kind of change must start. +# +# `suites` is a list of globs over suite ids; `["*"]` means every suite and +# `[]` means none. + +[[case]] +name = "server crate: the whole fuzz fleet except the pruning suite" +note = "A change to `src/api` reaches every suite that builds the server binary." +paths = [ + "src/api/src/lib.rs", +] +suites = [ + "fuzz-standalone/database-and-regular-table", + "fuzz-standalone/logical-table", + "fuzz-unstable/standalone", + "fuzz-distributed/database-and-regular-table", + "fuzz-distributed/logical-table", + "fuzz-distributed/repartition", + "fuzz-chaos/migration", + "fuzz-chaos/failover", + "fuzz-chaos/repartition", + "export-import-v2", +] + +[[case]] +name = "storage engine: every fuzz suite, including remote-WAL pruning" +note = "logical-pruning needs engine and DDL code, so an engine change starts it." +paths = [ + "src/mito2/src/lib.rs", +] +suites = [ + "fuzz-standalone/*", + "fuzz-unstable/*", + "fuzz-distributed/*", + "fuzz-chaos/*", + "export-import-v2", +] + +[[case]] +name = "in-process DDL code registers the procedures standalone executes" +paths = [ + "src/common/meta/src/lib.rs", +] +suites = [ + "fuzz-standalone/*", + "fuzz-unstable/*", + "fuzz-distributed/*", + "fuzz-chaos/*", + "export-import-v2", + "sqlness-kvbackend/*", +] + +[[case]] +name = "metasrv only: distributed and chaos families" +note = "Standalone builds a DdlManager in-process and never links a metasrv." +paths = [ + "src/meta-srv/src/discovery/lease.rs", +] +suites = [ + "fuzz-distributed/database-and-regular-table", + "fuzz-distributed/logical-table", + "fuzz-distributed/repartition", + "fuzz-distributed/logical-pruning", + "fuzz-chaos/migration", + "fuzz-chaos/failover", + "fuzz-chaos/repartition", + "sqlness-kvbackend/*", +] + +[[case]] +name = "CLI only touches the export/import path" +paths = [ + "src/cli/src/lib.rs", +] +suites = [ + "export-import-v2", +] + +[[case]] +name = "PromQL crate: no suite" +note = "rust.yml and the promql sqlness cases own it; the fuzz suites speak SQL." +paths = [ + "src/promql/src/lib.rs", +] +suites = [] + +[[case]] +name = "PromQL module inside the query crate is not fuzzable either" +note = "`@server` claims `src/query/**`, so the module must be exempt." +paths = [ + "src/query/src/promql/src/lib.rs", +] +suites = [] + +[[case]] +name = "flow engine: no suite" +paths = [ + "src/flow/src/lib.rs", +] +suites = [] + +[[case]] +name = "mixed diff: the PromQL file subtracts nothing" +note = "The engine change still starts every suite." +paths = [ + "src/mito2/src/lib.rs", + "src/promql/src/lib.rs", +] +suites = [ + "fuzz-standalone/*", + "fuzz-unstable/*", + "fuzz-distributed/*", + "fuzz-chaos/*", + "export-import-v2", +] + +[[case]] +name = "crate added after the rules were written" +note = "No suite claims it, so the gate widens instead of skipping everything." +paths = [ + "src/brand-new/src/lib.rs", +] +suites = ["*"] + +[[case]] +name = "integration harness change" +paths = [ + "tests-integration/tests/fuzz.rs", +] +suites = ["*"] + +[[case]] +name = "documentation only" +paths = [ + "docs/readme.md", +] +suites = [] + +[[case]] +name = "kind registry setup: every suite" +note = "The distributed and chaos fuzz jobs start their cluster with this script." +paths = [ + ".github/scripts/kind-with-registry.sh", +] +suites = ["*"] + +[[case]] +name = "fuzz target orchestration: every suite" +note = "Fuzz jobs execute targets through the `fuzz-test` action." +paths = [ + ".github/scripts/run-fuzz-targets.sh", +] +suites = ["*"] diff --git a/.github/ci/tests/test_ci_scope.py b/.github/ci/tests/test_ci_scope.py new file mode 100755 index 00000000000..77839aaaffa --- /dev/null +++ b/.github/ci/tests/test_ci_scope.py @@ -0,0 +1,1018 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Offline tests for the integration CI admission gate.""" + +import contextlib +import fnmatch +import io +import json +import os +import re +import pathlib +import sys +import tempfile +import tomllib +import unittest +from urllib.parse import parse_qs, urlparse + +GATE_DIR = pathlib.Path(__file__).resolve().parents[1] +WORKFLOWS = GATE_DIR.parent / "workflows" +REPO_ROOT = GATE_DIR.parents[1] +sys.path.insert(0, str(GATE_DIR)) + +import ci_scope # noqa: E402 +import ci_scope_rules # noqa: E402 + +SCOPE_CASES = pathlib.Path(__file__).resolve().parent / "fixtures" / "scope-cases.toml" +from ci_scope_rules import ( # noqa: E402 + ALWAYS_RUN, + PATH_GROUPS, + SUITES, + suite_paths, +) + +def file_entry(name, patch="@@ -1 +1 @@\n- old\n+ new\n", status="modified"): + entry = { + "filename": name, + "status": status, + "additions": 1, + "deletions": 1, + } + if patch is not None: + entry["patch"] = patch + return entry + + +class FakeHTTP: + """Records requests and serves canned GitHub API responses.""" + + def __init__(self): + self.calls = [] + self.file_pages = {1: []} + self.labels = [] + self.pull_status = 200 + self.draft = False + self.head_sha = "" + self.base_sha = "" + self.file_queries = [] + + def __call__(self, method, url, headers, body=None, timeout=None): + parsed = urlparse(url) + query = parse_qs(parsed.query) + self.calls.append((method, parsed.path, query.get("page", ["1"])[0])) + if parsed.path.endswith("/files"): + self.file_queries.append(dict(query)) + page = int(query.get("page", ["1"])[0]) + per_page = int(query.get("per_page", ["100"])[0]) + items = [] + for pageno in sorted(self.file_pages): + canned = self.file_pages[pageno] + if isinstance(canned, int): + # An int stands for an HTTP error status for that page. + if pageno == page: + return canned, b"{}" + continue + items.extend(canned) + # `file_pages` is stored at per_page=100; honour the requested + # window so a probe at per_page=1 cannot read past item 1. + start = (page - 1) * per_page + return 200, json.dumps(items[start : start + per_page]).encode() + if parsed.path.endswith("/pulls/1"): + if self.pull_status != 200: + return self.pull_status, b"{}" + return 200, json.dumps( + { + "labels": [{"name": name} for name in self.labels], + "head": {"sha": self.head_sha}, + "base": {"sha": self.base_sha}, + } + ).encode() + return 404, b"{}" + + +class ScopeTestCase(unittest.TestCase): + def setUp(self): + self.fake = FakeHTTP() + self._original = ci_scope._request + ci_scope._request = self.fake + self._environ = dict(os.environ) + for key in ["CI_SCOPE_FORCE_ALL"]: + os.environ.pop(key, None) + os.environ["GITHUB_API_URL"] = "https://api.github.com" + + def tearDown(self): + ci_scope._request = self._original + os.environ.clear() + os.environ.update(self._environ) + + def plan(self, files, pages=None, event="pull_request", extra_args=(), pr=1): + if pages is None: + pages = {1: files} + self.fake.file_pages = dict(pages) + args = ci_scope.parse_args( + ["--repo", "GreptimeTeam/greptimedb", "--pr", str(pr), "--event", event] + + list(extra_args) + ) + return ci_scope.build_plan(args, "token") + + def assert_all_run(self, plan): + for suite_id, entry in plan["suites"].items(): + self.assertTrue(entry["run"], suite_id) + + +class GlobTests(unittest.TestCase): + def test_double_star_crosses_directories(self): + regex = ci_scope.glob_to_regex("src/**/*.rs") + self.assertTrue(regex.match("src/a.rs")) + self.assertTrue(regex.match("src/a/b/c.rs")) + self.assertFalse(regex.match("tests/a.rs")) + + def test_single_star_stays_in_one_segment(self): + regex = ci_scope.glob_to_regex("src/*.rs") + self.assertTrue(regex.match("src/main.rs")) + self.assertFalse(regex.match("src/cmd/main.rs")) + self.assertFalse(regex.match("src/main.toml")) + + def test_question_mark_matches_one_character(self): + regex = ci_scope.glob_to_regex("a?.rs") + self.assertTrue(regex.match("ab.rs")) + self.assertFalse(regex.match("abc.rs")) + self.assertFalse(regex.match("a/.rs")) + + def test_suffix_glob(self): + regex = ci_scope.glob_to_regex(".cargo/**") + self.assertTrue(regex.match(".cargo/config.toml")) + self.assertFalse(regex.match("cargo/config.toml")) + + def test_suite_groups_expand(self): + for group in PATH_GROUPS: + self.assertTrue(PATH_GROUPS[group]) + self.assertTrue(any( + entry == "@" + group + for suite in SUITES.values() + for entry in suite["paths"] + ), group) + paths = suite_paths(SUITES["fuzz-distributed/repartition"]) + self.assertIn("src/mito2/**", paths) + self.assertIn("src/meta-srv/**", paths) + self.assertNotIn("@server", paths) + + def test_every_gate_routes_to_an_output(self): + # Ask the producer, not a copy of its key list: a new gate must fail + # here until `derive_outputs` routes it to a gate key. + plan = { + "mode": "static", + "full": True, + "notes": [], + "suites": { + suite_id: {"run": True, "source": "static", "reason": ""} + for suite_id in SUITES + }, + } + outputs, _ = ci_scope.derive_outputs(plan) + gates = {suite["gate"] for suite in SUITES.values()} + self.assertLessEqual(gates, set(outputs)) + + def test_suite_metadata(self): + for suite_id, suite in SUITES.items(): + self.assertEqual(suite_id, suite_id.strip()) + self.assertTrue(suite["description"]) + self.assertEqual(suite_id.split("/")[0].replace("-", "_"), suite["gate"]) + if suite["gate"] in ci_scope.FUZZ_GATES: + self.assertEqual(ci_scope.suite_group(suite_id), suite_id.split("/", 1)[1]) + + +class ForcedModeTests(ScopeTestCase): + def test_merge_group_forces_without_http(self): + plan = self.plan([], event="merge_group") + self.assertEqual(plan["mode"], "forced") + self.assert_all_run(plan) + self.assertEqual(self.fake.calls, []) + + def test_schedule_and_workflow_dispatch_force(self): + for event in ("schedule", "workflow_dispatch"): + plan = self.plan([], event=event) + self.assertEqual(plan["mode"], "forced", event) + + def test_force_all_env_forces(self): + os.environ["CI_SCOPE_FORCE_ALL"] = "true" + plan = self.plan([], event="pull_request") + self.assertEqual(plan["mode"], "forced") + + def test_ci_full_label_forces(self): + self.fake.labels = ["ci:full"] + plan = self.plan([file_entry("src/mito2/src/lib.rs")]) + self.assertEqual(plan["mode"], "forced") + self.assert_all_run(plan) + + +class UncoveredChangeTests(ScopeTestCase): + def test_uncovered_crate_runs_everything(self): + # `src/common/telemetry` is mapped by the server group; a crate added + # later is not, and must widen the gate. + plan = self.plan([file_entry("src/common/brand-new/src/lib.rs")]) + self.assertTrue(plan["full"], plan["notes"]) + self.assertEqual(plan["suites"]["export-import-v2"]["source"], "uncovered") + self.assertIn("uncovered build change", " ".join(plan["notes"])) + + def test_mapped_crate_keeps_the_path_decision(self): + plan = self.plan([file_entry("src/query/src/lib.rs")]) + self.assertFalse(plan["full"]) + self.assertTrue(plan["suites"]["fuzz-standalone/logical-table"]["run"]) + # `src/query` reaches `COPY DATABASE` and the chaos scenarios too. + self.assertTrue(plan["suites"]["export-import-v2"]["run"]) + self.assertTrue(plan["suites"]["fuzz-chaos/migration"]["run"]) + + def test_exempt_trees_do_not_widen(self): + plan = self.plan( + [file_entry("src/promql/src/lib.rs"), file_entry("src/flow/src/lib.rs")] + ) + self.assertFalse(plan["full"]) + for entry in plan["suites"].values(): + self.assertFalse(entry["run"], entry) + + def test_exempt_tree_is_not_claimed_by_a_broader_glob(self): + # `@server` claims `src/query/**`; the PromQL module under it must be + # dropped before matching, and must not count as uncovered either. + plan = self.plan([file_entry("src/query/src/promql/src/lib.rs")]) + self.assertFalse(plan["full"]) + for entry in plan["suites"].values(): + self.assertFalse(entry["run"], entry) + + +class StaticDecisionTests(ScopeTestCase): + def outputs(self, plan): + return ci_scope.derive_outputs(plan)[0] + + def test_always_run_forces_everything(self): + plan = self.plan([file_entry("Cargo.lock")]) + self.assertEqual(plan["mode"], "static") + self.assertTrue(plan["full"]) + self.assert_all_run(plan) + + def test_meta_only_skips_standalone_fuzz(self): + plan = self.plan([file_entry("src/meta-srv/src/lib.rs")]) + self.assertEqual(plan["mode"], "static") + outputs, legs = ci_scope.derive_outputs(plan) + self.assertEqual(outputs["fuzz_standalone"], "false") + self.assertEqual(outputs["fuzz_unstable"], "false") + self.assertIn("migration", legs["fuzz_chaos"]) + self.assertIn("logical-pruning", legs["fuzz_distributed"]) + self.assertEqual(outputs["export_import_v2"], "false") + self.assertEqual(outputs["any_fuzz"], "true") + self.assertEqual(outputs["build_ci_needed"], "true") + + def test_flow_and_promql_skip_every_gated_suite(self): + plan = self.plan( + [file_entry("src/flow/src/lib.rs"), file_entry("src/promql/src/lib.rs")] + ) + self.assertEqual(plan["mode"], "static") + for entry in plan["suites"].values(): + self.assertFalse(entry["run"], entry) + outputs, legs = ci_scope.derive_outputs(plan) + self.assertEqual(outputs["any_fuzz"], "false") + self.assertEqual(outputs["build_ci_needed"], "false") + self.assertEqual(outputs["export_import_v2"], "false") + for gate in ci_scope.FUZZ_GATES: + self.assertEqual(outputs[gate], "false") + self.assertTrue(all(not values for values in legs.values())) + + def test_docs_only_short_circuits(self): + plan = self.plan([file_entry("docs/readme.md")]) + self.assertEqual(plan["mode"], "static") + self.assertIn("no meaningful changes", " ".join(plan["notes"])) + + def test_mapped_change_records_the_path_rule_source(self): + plan = self.plan([file_entry("src/mito2/src/lib.rs")]) + self.assertEqual(plan["mode"], "static") + self.assertEqual(plan["suites"]["fuzz-standalone/logical-table"]["source"], "static") + + def test_file_cap_truncates(self): + files = [file_entry("src/mito2/f%d.rs" % index) for index in range(300)] + extra = [file_entry("src/mito2/overflow.rs")] + plan = self.plan(files, pages={1: files[:100], 2: files[100:200], 3: files[200:], 4: extra}) + self.assertTrue(plan["full"]) + self.assert_all_run(plan) + self.assertTrue(all(entry["source"] == "always-run" for entry in plan["suites"].values())) + + def test_exactly_max_files_is_not_truncated(self): + files = [file_entry("src/promql/f%d.rs" % index) for index in range(300)] + plan = self.plan([], pages={1: files[:100], 2: files[100:200], 3: files[200:], 4: []}) + self.assertFalse(plan["full"], plan["notes"]) + self.assertNotIn("truncated", " ".join(plan["notes"])) + self.assertEqual(len(self.fake.file_queries), 4) + self.assertEqual( + self.fake.file_queries[-1], {"per_page": ["100"], "page": ["4"]} + ) + + def test_force_push_after_the_run_widens_the_gate(self): + self.fake.head_sha = "b" * 40 + plan = self.plan( + [file_entry("src/promql/src/lib.rs")], + extra_args=("--head-sha", "a" * 40, "--base-sha", "c" * 40), + ) + self.assertTrue(plan["full"]) + self.assertIn("head moved from aaaaaaaaaaaa", " ".join(plan["notes"])) + + def test_matching_revisions_keep_the_static_verdict(self): + self.fake.head_sha = "a" * 40 + self.fake.base_sha = "c" * 40 + plan = self.plan( + [file_entry("src/promql/src/lib.rs")], + extra_args=("--head-sha", "a" * 40, "--base-sha", "c" * 40), + ) + self.assertFalse(plan["full"]) + self.assertNotIn("moved", " ".join(plan["notes"])) + + def test_pull_request_is_read_once_per_run(self): + self.plan([file_entry("src/mito2/src/lib.rs")]) + pulls = [ + path + for _, path, _ in self.fake.calls + if path.endswith("/pulls/1") + ] + self.assertEqual(len(pulls), 1) + + def test_failed_completeness_probe_fails_instead_of_narrowing(self): + # Raising leaves the gate outputs empty, which the workflow treats as + # "run everything"; returning False here would silently classify a + # truncated file list as complete. + files = [file_entry("src/mito2/f%d.rs" % index) for index in range(300)] + with self.assertRaises(ci_scope.ScopeError): + self.plan( + files, + pages={1: files[:100], 2: files[100:200], 3: files[200:], 4: 500}, + ) + + def test_file_list_pagination_stops_at_short_page(self): + files = [file_entry("src/mito2/f%d.rs" % index) for index in range(120)] + self.plan(files, pages={1: files[:100], 2: files[100:]}) + pages = [ + page for _, path, page in self.fake.calls if path.endswith("/files") + ] + self.assertEqual(pages, ["1", "2"]) + + +# Crates no gated suite exercises. Changing one either runs everything through +# the always-run floor, or reaches CI that is not gated here. +UNMAPPED_CRATES = { + "src/flow": "flow execution is covered by rust.yml and sqlness", + "src/promql": "PromQL execution is covered by rust.yml and sqlness", + "tests-fuzz": "in ALWAYS_RUN", + "tests-integration": "in ALWAYS_RUN", +} + + +def workspace_crates(): + """Every workspace member directory, so nested crates are not collapsed.""" + data = tomllib.loads((REPO_ROOT / "Cargo.toml").read_text(encoding="utf-8")) + return sorted(member.rstrip("/") for member in data["workspace"]["members"]) + + +def rules_doc(): + return { + "matching": { + "always_run": ["Cargo.toml"], + "src_tree": "src/", + "exempt": [{"path": "src/flow/", "reason": "test fixture"}], + }, + "gates": {"fuzz_x": "fuzztest"}, + "groups": {"server": ["src/a/**"]}, + "suites": { + "fuzz-x/g": { + "gate": "fuzz_x", + "paths": ["@server"], + "description": "a suite", + } + }, + } + + +class RulesFileTests(unittest.TestCase): + def test_repository_rules_are_valid(self): + rules = ci_scope_rules.load() + self.assertEqual(len(rules["suites"]), 13) + + def test_unknown_group_is_rejected(self): + doc = rules_doc() + doc["suites"]["fuzz-x/g"]["paths"] = ["@nope"] + with self.assertRaises(ci_scope_rules.RulesError): + ci_scope_rules.validate(doc) + + def test_group_cycle_is_rejected(self): + doc = rules_doc() + doc["groups"]["a"] = ["@b"] + doc["groups"]["b"] = ["@a"] + with self.assertRaises(ci_scope_rules.RulesError): + ci_scope_rules.validate(doc) + + def test_gate_must_match_the_suite_prefix(self): + doc = rules_doc() + doc["suites"]["fuzz-x/g"]["gate"] = "fuzz_y" + with self.assertRaises(ci_scope_rules.RulesError): + ci_scope_rules.validate(doc) + + def test_unknown_key_in_matching_is_ignored_but_missing_is_not(self): + doc = rules_doc() + del doc["matching"]["src_tree"] + with self.assertRaises(ci_scope_rules.RulesError): + ci_scope_rules.validate(doc) + + def test_validator_entry_point_succeeds_on_the_repository_rules(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(ci_scope_rules.main([]), 0) + self.assertIn("scope rules ok", out.getvalue()) + + def test_malformed_toml_names_the_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "rules.toml" + path.write_text("this is not toml [[[") + with self.assertRaises(ci_scope_rules.RulesError) as ctx: + ci_scope_rules.load(path) + self.assertIn("not valid TOML", str(ctx.exception)) + + +def stub_rules(**overrides): + """A rules dict in the shape `validate` returns, naming only real paths.""" + rules = { + "always_run": ["Cargo.toml"], + "src_tree": "src/", + "exempt_trees": [], + "gates": {"fuzz_x": "fuzztest"}, + "groups": {"server": ["Cargo.toml"]}, + "suites": { + "fuzz-x": { + "gate": "fuzz_x", + "paths": ["@server"], + "description": "a suite", + } + }, + } + rules.update(overrides) + return rules + + +class PathCheckTests(unittest.TestCase): + def test_repository_rules_name_only_existing_paths(self): + self.assertEqual(ci_scope_rules.missing_paths(), []) + + def test_a_vanished_path_is_reported_with_its_prefix(self): + doc = stub_rules() + doc["suites"]["fuzz-x"]["paths"] = ["@server", "src/does-not-exist/**"] + self.assertEqual( + ci_scope_rules.missing_paths(doc), + [("src/does-not-exist/**", "src/does-not-exist/")], + ) + + def test_patterns_without_a_fixed_prefix_are_skipped(self): + doc = stub_rules(groups={"server": ["*.md"]}, always_run=["*.md"]) + self.assertEqual(ci_scope_rules.missing_paths(doc), []) + + + def test_cli_reports_a_missing_path(self): + text = """\ +[matching] +always_run = ["Cargo.toml"] +src_tree = "src/" + +[[matching.exempt]] +path = "src/flow/" +reason = "test fixture" + +[[matching.exempt]] +path = "src/promql/" +reason = "test fixture" + +[gates] +fuzz_x = "fuzztest" + +[groups] +server = ["src/does-not-exist/**"] + +[suites."fuzz-x"] +gate = "fuzz_x" +paths = ["@server"] +description = "a suite" +""" + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "scope-rules.toml" + path.write_text(text) + original = ci_scope_rules.RULES_PATH + ci_scope_rules.RULES_PATH = path + err = io.StringIO() + try: + with contextlib.redirect_stderr(err): + code = ci_scope_rules.main(["--check-paths"]) + finally: + ci_scope_rules.RULES_PATH = original + self.assertEqual(code, 1) + self.assertIn("missing path: src/does-not-exist/**", err.getvalue()) + + def test_cli_succeeds_on_the_repository_rules(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = ci_scope_rules.main(["--check-paths"]) + self.assertEqual(code, 0) + self.assertEqual(err.getvalue(), "") + + + +class RulesReasonTests(unittest.TestCase): + def test_exclusion_without_a_reason_is_rejected(self): + doc = rules_doc() + del doc["matching"]["exempt"][0]["reason"] + with self.assertRaises(ci_scope_rules.RulesError): + ci_scope_rules.validate(doc) + + def test_blank_reason_is_rejected(self): + doc = rules_doc() + doc["matching"]["exempt"][0]["reason"] = " " + with self.assertRaises(ci_scope_rules.RulesError): + ci_scope_rules.validate(doc) + + def test_repository_rules_explain_every_exclusion(self): + raw = tomllib.loads(ci_scope_rules.RULES_PATH.read_text()) + matching = raw["matching"] + self.assertTrue(matching["exempt"]) + for entry in matching["exempt"]: + self.assertTrue(entry["reason"].strip(), entry) + + +class ScopeCaseTests(ScopeTestCase): + """Replay `fixtures/scope-cases.toml` through the real classifier.""" + + @staticmethod + def cases(): + return tomllib.loads(SCOPE_CASES.read_text())["case"] + + def test_every_case_matches_the_classifier(self): + for case in self.cases(): + with self.subTest(case=case["name"]): + plan = self.plan([file_entry(path) for path in case["paths"]]) + expected = { + suite_id + for suite_id in ci_scope.SUITES + if any( + fnmatch.fnmatchcase(suite_id, pattern) + for pattern in case["suites"] + ) + } + actual = { + suite_id + for suite_id, entry in plan["suites"].items() + if entry["run"] + } + self.assertEqual(expected, actual, plan["notes"]) + + def test_cases_cover_every_suite(self): + covered = set() + for case in self.cases(): + covered |= { + suite_id + for suite_id in ci_scope.SUITES + if any( + fnmatch.fnmatchcase(suite_id, pattern) + for pattern in case["suites"] + ) + } + self.assertEqual(covered, set(ci_scope.SUITES)) + + +class SummaryTests(ScopeTestCase): + def summary(self, plan, path): + args = ci_scope.parse_args( + ["--repo", "GreptimeTeam/greptimedb", "--pr", "1", "--summary", path] + ) + ci_scope.emit(plan, args) + return pathlib.Path(path).read_text() + + def test_summary_names_the_files_that_matched(self): + plan = self.plan([file_entry("src/mito2/src/lib.rs")]) + with tempfile.TemporaryDirectory() as tmp: + text = self.summary(plan, str(pathlib.Path(tmp) / "summary.md")) + self.assertIn("### CI scope: mode=static", text) + row = next( + line + for line in text.splitlines() + if line.startswith("| `fuzz-standalone/logical-table`") + ) + self.assertIn("| run |", row) + self.assertIn("`src/mito2/src/lib.rs`", row) + + def test_summary_marks_a_skipped_suite_without_files(self): + plan = self.plan([file_entry("src/cli/src/lib.rs")]) + with tempfile.TemporaryDirectory() as tmp: + text = self.summary(plan, str(pathlib.Path(tmp) / "summary.md")) + row = next( + line + for line in text.splitlines() + if line.startswith("| `fuzz-standalone/database-and-regular-table`") + ) + self.assertIn("| skip |", row) + self.assertTrue(row.endswith("| — |"), row) + + def test_summary_truncates_long_file_lists(self): + files = [file_entry(f"src/mito2/src/f{index}.rs") for index in range(5)] + plan = self.plan(files) + with tempfile.TemporaryDirectory() as tmp: + text = self.summary(plan, str(pathlib.Path(tmp) / "summary.md")) + self.assertIn("+2 more", text) + + def test_summary_appends_instead_of_overwriting(self): + plan = self.plan([file_entry("src/mito2/src/lib.rs")]) + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "summary.md" + path.write_text("existing\n") + self.summary(plan, str(path)) + text = path.read_text() + self.assertTrue(text.startswith("existing\n")) + self.assertEqual(text.count("### CI scope"), 1) + + def test_summary_carries_the_notes(self): + plan = self.plan([file_entry("Cargo.lock")]) + with tempfile.TemporaryDirectory() as tmp: + text = self.summary(plan, str(pathlib.Path(tmp) / "summary.md")) + self.assertIn("
notes", text) + + +class CrateCoverageTests(unittest.TestCase): + """A new crate must not silently match no suite: the runtime net would + catch it by running everything, but the rule table should be updated.""" + + def test_every_workspace_crate_is_mapped_or_excused(self): + patterns = [ + ci_scope.glob_to_regex(path) + for suite in SUITES.values() + for path in suite_paths(suite) + ] + unmapped = [ + crate + for crate in workspace_crates() + if crate not in UNMAPPED_CRATES + and not any(p.match(crate + "/src/lib.rs") for p in patterns) + ] + self.assertEqual(unmapped, [], "crate matches no suite and no excuse") + + def test_excuses_reference_existing_crates(self): + stale = sorted(set(UNMAPPED_CRATES) - set(workspace_crates())) + self.assertEqual(stale, [], "excuse points at a crate that is gone") + + +class PathRuleCoverageTests(ScopeTestCase): + """A rule must select every suite the change can actually break.""" + + def test_copy_parser_selects_export_import(self): + plan = self.plan([file_entry("src/sql/src/parsers/copy_parser.rs")]) + self.assertTrue(plan["suites"]["export-import-v2"]["run"]) + + def test_shared_ddl_selects_standalone_fuzz(self): + plan = self.plan([file_entry("src/common/meta/src/ddl/create_table.rs")]) + for suite in ( + "fuzz-standalone/database-and-regular-table", + "fuzz-standalone/logical-table", + "fuzz-unstable/standalone", + ): + self.assertTrue(plan["suites"][suite]["run"], suite) + + def test_sqlness_runner_selects_the_kvbackend_legs(self): + # `--setup-pg` / `--setup-mysql` live in the runner and only these legs + # pass them, so a change there must not skip the only job that uses it. + plan = self.plan([file_entry("tests/runner/src/server_mode.rs")]) + for suite in ("sqlness-kvbackend/metasrv-postgres", "sqlness-kvbackend/metasrv-mysql"): + self.assertTrue(plan["suites"][suite]["run"], suite) + + def test_shared_ddl_selects_export_import(self): + # The E2E creates databases, tables and views and recreates them on + # import, all through the in-process DDL manager. + plan = self.plan([file_entry("src/common/procedure/src/lib.rs")]) + self.assertTrue(plan["suites"]["export-import-v2"]["run"]) + + def test_group_references_resolve_recursively(self): + self.assertIn("src/common/meta/**", ci_scope.suite_paths(SUITES["fuzz-chaos/migration"])) + + +class OutputTests(ScopeTestCase): + def test_flat_outputs(self): + plan = self.plan([file_entry("src/mito2/src/lib.rs")]) + outputs, legs = ci_scope.derive_outputs(plan) + self.assertEqual( + set(outputs), + { + "any_fuzz", + "build_ci_needed", + "fuzz_standalone", + "fuzz_distributed", + "fuzz_chaos", + "fuzz_unstable", + "export_import_v2", + "sqlness_kvbackend", + }, + ) + for key in outputs: + self.assertIn(outputs[key], ("true", "false"), key) + self.assertEqual( + sorted(legs["fuzz_standalone"]), + ["database-and-regular-table", "logical-table"], + ) + + def test_emit_writes_the_gate_outputs(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + out_path = os.path.join(tmp, "github_output") + args = ci_scope.parse_args( + [ + "--repo", + "GreptimeTeam/greptimedb", + "--pr", + "1", + "--event", + "merge_group", + "--github-output", + out_path, + ] + ) + ci_scope.emit(ci_scope.build_plan(args, "token"), args) + with open(out_path, encoding="utf-8") as handle: + text = handle.read() + self.assertIn("any_fuzz=true" + "\n", text) + self.assertIn("fuzz_unstable=true", text) + self.assertIn("fuzz_chaos=true", text) + self.assertEqual(text.count("\n"), 8) + + + +class GateJobContractTests(unittest.TestCase): + """The rules say which job each gate switches; prove the YAML agrees.""" + + WORKFLOW = WORKFLOWS / "integration.yml" + + def job_blocks(self): + blocks = {} + current = None + for line in self.WORKFLOW.read_text().splitlines(): + marker = re.match(r"^ ([A-Za-z0-9_-]+):$", line) + if marker: + current = marker.group(1) + blocks[current] = [] + elif current: + blocks[current].append(line) + return {name: "\n".join(lines) for name, lines in blocks.items()} + + def test_every_gate_names_a_job_that_reads_it(self): + blocks = self.job_blocks() + for gate, job in ci_scope_rules.GATE_JOBS.items(): + self.assertIn(job, blocks, gate) + self.assertIn(f"needs.scope.outputs.{gate} != 'false'", blocks[job], gate) + + def test_every_gated_job_is_named_by_a_gate(self): + derived = {"any_fuzz", "build_ci_needed"} + declared = set(ci_scope_rules.GATE_JOBS) + for name, block in self.job_blocks().items(): + for gate in re.findall(r"needs\.scope\.outputs\.([a-z0-9_]+)", block): + self.assertIn(gate, declared | derived, name) + + def test_a_suite_gate_missing_from_the_table_is_rejected(self): + doc = rules_doc() + del doc["gates"]["fuzz_x"] + with self.assertRaises(ci_scope_rules.RulesError) as ctx: + ci_scope_rules.validate(doc) + self.assertIn("missing from [gates]", str(ctx.exception)) + + def test_gates_no_suite_uses_are_rejected(self): + doc = rules_doc() + doc["gates"]["fuzz_y"] = "fuzztest" + with self.assertRaises(ci_scope_rules.RulesError) as ctx: + ci_scope_rules.validate(doc) + self.assertIn("no suite uses", str(ctx.exception)) + + +class WorkflowContractTests(unittest.TestCase): + """Keep the YAML gate contract in sync with the classifier outputs.""" + + WORKFLOW = WORKFLOWS / "integration.yml" + + def job_blocks(self): + text = self.WORKFLOW.read_text() + blocks = {} + current = None + for line in text.splitlines(): + marker = re.match(r"^ ([A-Za-z0-9_-]+):$", line) + if marker: + current = marker.group(1) + blocks[current] = [] + elif current: + blocks[current].append(line) + return {name: "\n".join(lines) for name, lines in blocks.items()} + + def test_every_script_a_gated_job_runs_is_always_run(self): + """A script a gated job executes must widen the gate. + + A change confined to such a script would otherwise select no suite, so + the jobs that run it are skipped exactly when they are worth running. + The reference is often not in the workflow: a composite action the job + `uses` can run the script, so the search follows those actions too. + """ + action_use = re.compile(r"uses:\s*\./(\.github/actions/[A-Za-z0-9_.-]+)") + script_ref = re.compile( + r"(?:\./)?(\.github/[A-Za-z0-9_./-]+\.(?:sh|py|js|cjs|ts))\b" + ) + always = [ci_scope.glob_to_regex(pattern) for pattern in ALWAYS_RUN] + + gated = [b for b in self.job_blocks().values() if "needs.scope.outputs" in b] + pending = {ref for block in gated for ref in script_ref.findall(block)} + pending |= {a for block in gated for a in action_use.findall(block)} + seen = set() + while pending: + reference = pending.pop() + if reference in seen: + continue + seen.add(reference) + if not reference.startswith(".github/actions/"): + continue + for suffix in ("action.yml", "action.yaml"): + path = REPO_ROOT / reference / suffix + if path.exists(): + text = path.read_text() + pending |= set(script_ref.findall(text)) + pending |= set(action_use.findall(text)) + break + uncovered = sorted( + ref for ref in seen if not any(pattern.match(ref) for pattern in always) + ) + self.assertEqual(uncovered, [], "a gated job runs a script ALWAYS_RUN misses") + + def test_gates_treat_a_missing_output_as_run(self): + blocks = self.job_blocks() + gated = [ + "export-import-v2-e2e", + "prepare-fuzz-targets", + "fuzztest", + "unstable-fuzztest", + "build-greptime-ci", + "distributed-fuzztest", + "distributed-fuzztest-with-chaos", + "sqlness-kvbackend", + ] + declared = set( + re.findall( + r"^ ([a-z0-9_]+): \$\{\{ steps\.classify\.outputs\.\1 \}\}$", + blocks["scope"], + re.MULTILINE, + ) + ) + self.assertIn("any_fuzz", declared) + seen = set() + for name in gated: + self.assertIn("scope", blocks[name], name) + match = re.search( + r"needs\.scope\.outputs\.([a-z0-9_]+) != 'false'", blocks[name] + ) + self.assertTrue(match, name) + seen.add(match.group(1)) + self.assertEqual(seen, declared) + + def test_gates_override_the_implicit_success_check(self): + # A condition without a status function carries an implicit success() + # over `needs`, so a scope job that times out or dies on the runner + # would skip the gates despite the fail-open output comparison. + blocks = self.job_blocks() + for name in ( + "export-import-v2-e2e", + "prepare-fuzz-targets", + "fuzztest", + "unstable-fuzztest", + "build-greptime-ci", + "distributed-fuzztest", + "distributed-fuzztest-with-chaos", + ): + block = blocks[name] + condition = re.search(r"^ if: \$\{\{ (.*?) \}\}$", block, re.M) + self.assertTrue(condition, name) + self.assertTrue(condition.group(1).startswith("!cancelled() && "), name) + needs = re.search(r"^ needs: \[(.*?)\]$", block, re.M) + self.assertTrue(needs, name) + for dep in [item.strip() for item in needs.group(1).split(",")]: + if not dep or dep == "scope": + continue + key = f"needs['{dep}'].result" if "-" in dep else f"needs.{dep}.result" + self.assertIn(key + " == 'success'", condition.group(1), name) + + def test_scope_job_cannot_fail_and_skip_the_gates(self): + block = self.job_blocks()["scope"] + self.assertNotIn("Fail-open defaults", block) + self.assertIn("github.event.merge_group.base_sha", block) + self.assertIn("CI_SCOPE_FORCE_ALL", block) + # Every step that can fail must be continue-on-error: a failed scope job + # would make GitHub skip the gated jobs instead of running them. + steps = block.split("\n - ") + guarded = [ + step + for step in steps + if step.startswith("uses: actions/checkout") + or step.startswith("name: Classify") + ] + self.assertEqual(len(guarded), 2) + for step in guarded: + self.assertIn("continue-on-error: true", step, step.splitlines()[0]) + + + +class LogRenderingTests(ScopeTestCase): + """The step log is where a triage starts; the verdict must be there.""" + + def sample_plan(self): + return { + "mode": "static", + "head_sha": "a" * 40, + "base_sha": "b" * 40, + "full": False, + "notes": ["always-run: Cargo.lock"], + "suites": { + "fuzz-standalone/logical-table": { + "run": True, + "source": "static", + "reason": "touches mito2", + }, + "fuzz-chaos/migration": { + "run": False, + "source": "static", + "reason": "meta docs only", + }, + }, + } + + def test_header_names_mode_and_full(self): + self.assertEqual( + ci_scope.render_log(self.sample_plan()).splitlines()[0], + "CI scope: mode=static full=false", + ) + + def test_one_line_per_suite_with_source_and_reason(self): + lines = ci_scope.render_log(self.sample_plan()).splitlines() + self.assertIn(" run fuzz-standalone/logical-table", lines[1]) + self.assertIn("static", lines[1]) + self.assertIn("touches mito2", lines[1]) + self.assertIn(" skip fuzz-chaos/migration", lines[2]) + self.assertIn("meta docs only", lines[2]) + + def test_gates_and_notes_are_printed(self): + log = ci_scope.render_log(self.sample_plan()) + self.assertIn(" gates: fuzz_standalone=true", log) + self.assertIn("any_fuzz=true", log) + self.assertIn("export_import_v2=false", log) + self.assertIn(" note: always-run: Cargo.lock", log) + + def test_a_failed_pull_request_lookup_is_annotated_and_runs_everything(self): + # Drive the real failure path: the label lookup and the revision + # comparison share one request, so a 500 here must widen the gate and + # still produce the warning the annotation promises. + self.fake.pull_status = 500 + plan = self.plan([file_entry("src/promql/src/lib.rs")]) + self.assertTrue(plan["full"]) + self.assertIn("pull request lookup failed", " ".join(plan["notes"])) + annotations = ci_scope.log_annotations(plan) + self.assertEqual(len(annotations), 1) + self.assertTrue(annotations[0].startswith("::warning::"), annotations) + self.assertIn("every suite runs", annotations[0]) + + def test_main_prints_the_log(self): + self.fake.file_pages = {1: [file_entry("src/mito2/src/lib.rs")]} + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + self.assertEqual( + ci_scope.main( + [ + "--repo", + "GreptimeTeam/greptimedb", + "--pr", + "1", + "--event", + "pull_request", + "--dry-run", + ] + ), + 0, + ) + text = stdout.getvalue() + self.assertIn("CI scope: mode=", text) + self.assertIn(" run fuzz-standalone/logical-table", text) + self.assertIn("any_fuzz=true", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/heavy-ci.md b/.github/heavy-ci.md new file mode 100644 index 00000000000..01179861ced --- /dev/null +++ b/.github/heavy-ci.md @@ -0,0 +1,37 @@ +# Heavy CI scheduling + +Checks, Rust CI, and Integration CI each run the shared GitHub script test +action before expensive builds and tests. A failed, skipped, or cancelled +script-test job blocks those jobs, including manually dispatched runs. Each +workflow runs its own preflight so it does not wait on another workflow run. + +Rust CI runs Windows and RISC-V compilation checks only on schedule or manual +dispatch, not on PRs or merge groups. Windows nextest and Windows SQLness run in the existing Nightly CI workflow, which also +supports manual dispatch. Its existing schedule is Tuesday through Friday at +07:00 Asia/Shanghai, not every day. No new Windows test job is added. + +Integration CI admits fuzz when a non-draft PR is opened or marked ready for +review, selecting suites with the existing diff-scope classifier. Its weekday +schedule (23:00 Asia/Shanghai, Monday through Friday) runs all fuzz suites. Ordinary pushes and reopening do not run fuzz; core +integration checks still run. Merge groups continue to skip fuzz. + +Re-running an ordinary push-triggered Integration CI run does not enable fuzz. +A new push can cancel an earlier ready-for-review run. Use an explicit fuzz +profile through the CI entry points below when later changes need fuzz coverage. + +For a same-repository branch, use **Run workflow** and select `fuzz_profile` +(`standalone`, `distributed`, `chaos`, or `all`). The default `none` runs core +integration without fuzz. The existing `/ci fuzz ...` commands remain available +for same-repository draft PRs with their existing authorization rules. + +Validate job admission with `node --test .github/scripts/heavy-ci-policy.test.cjs`. +These tests verify triggering decisions, not runner availability or performance. + +## Narthex branch + +GitHub schedules run only on the default branch. They do not test `narthex` +automatically. To validate this branch, manually dispatch Rust CI, Integration +CI (selecting a fuzz profile), or Nightly CI with `narthex` as the ref. + +Land these scope rules on `narthex` before updating dependent PR branches: +the classifier checks out the PR base revision, not the PR head. diff --git a/.github/scripts/heavy-ci-policy.test.cjs b/.github/scripts/heavy-ci-policy.test.cjs new file mode 100644 index 00000000000..9dfd030796e --- /dev/null +++ b/.github/scripts/heavy-ci-policy.test.cjs @@ -0,0 +1,118 @@ +const { readFileSync } = require('node:fs'); +const { resolve } = require('node:path'); +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const workflows = resolve(__dirname, '../workflows'); +const integration = readFileSync(resolve(workflows, 'integration.yml'), 'utf8'); +const suites = ['fuzztest', 'unstable-fuzztest', 'distributed-fuzztest', 'distributed-fuzztest-with-chaos']; +const prerequisites = ['prepare-fuzz-targets', 'build-greptime-ci']; +function enabled(job, { event = 'pull_request', action = 'synchronize', draft = false, attempt = 1, profile = 'none', repository = 'GreptimeTeam/greptimedb', outputs = {}, results = {}, cancelled = false, workflow = integration } = {}) { + const block = workflow.split(` ${job}:\n`)[1].split(/\n [a-z][\w-]*:/)[0]; + if (block.includes("needs['github-script-tests']")) { + assert.match(block, /^ needs: \[.*github-script-tests.*\]/m); + } + const expression = block.match(/^ if: \$\{\{ (.+) \}\}/m)[1]; + // These job guards use only operators whose semantics match JS for this fixture. + return new Function('github', 'inputs', 'needs', 'cancelled', `return (${expression});`)( + { repository, event_name: event, event: { action, pull_request: { draft } }, run_attempt: attempt }, + { fuzz_profile: profile, ci_command: false }, + { scope: { outputs }, ...Object.fromEntries(['github-script-tests', 'build', ...prerequisites].map(name => [name, { result: results[name] || 'success' }])) }, + () => cancelled + ); +} +test('fuzz event policy on ready PR admission and schedule, not every update', () => { + for (const job of [...suites, ...prerequisites]) { + for (const action of ['opened', 'ready_for_review']) assert.equal(enabled(job, { action }), true, job); + for (const action of ['synchronize', 'reopened']) assert.equal(enabled(job, { action }), false, job); + assert.equal(enabled(job, { event: 'schedule' }), true, job); + assert.equal(enabled(job, { event: 'merge_group' }), false, job); + assert.equal(enabled(job, { attempt: 2 }), false, job); + assert.equal(enabled(job, { action: 'opened', draft: true }), false, job); + assert.equal(enabled(job, { attempt: 2, draft: true }), false, job); + assert.equal(enabled(job, { event: 'schedule', repository: 'fork/greptimedb' }), false, job); + } +}); +test('manual profiles select only their suite and all required build jobs', () => { + const expected = { none: [], standalone: suites.slice(0, 2), distributed: [suites[2]], chaos: [suites[3]], all: suites }; + for (const [profile, jobs] of Object.entries(expected)) { + assert.deepEqual(suites.filter(job => enabled(job, { event: 'workflow_dispatch', profile })), jobs); + for (const job of prerequisites) assert.equal(enabled(job, { event: 'workflow_dispatch', profile }), profile !== 'none'); + } +}); +test('core integration tests still run on PR updates and merge groups', () => { + for (const job of ['build', 'sqlness', 'export-import-v2-e2e', 'run-multi-lang-tests']) { + assert.equal(enabled(job), true, job); + assert.equal(enabled(job, { event: 'merge_group' }), true, job); + } +}); +test('Windows compilation stays in Rust CI; test coverage remains in Nightly CI', () => { + const rust = readFileSync(resolve(workflows, 'rust.yml'), 'utf8'); + const nightly = readFileSync(resolve(workflows, 'nightly-ci.yml'), 'utf8'); + assert.match(rust, /^ check-windows:/m); + assert.doesNotMatch(rust, /^ test-windows:/m); + assert.match(nightly, /^ test-on-windows:/m); + assert.match(nightly, /^ sqlness-windows:/m); + assert.match(nightly, /cargo nextest run -F dashboard/); + assert.match(nightly, /^ workflow_dispatch:/m); +}); + +test('scope exclusions and dependency failures compose with the event policy', () => { + const gates = { + 'prepare-fuzz-targets': 'any_fuzz', 'build-greptime-ci': 'build_ci_needed', + fuzztest: 'fuzz_standalone', 'unstable-fuzztest': 'fuzz_unstable', + 'distributed-fuzztest': 'fuzz_distributed', 'distributed-fuzztest-with-chaos': 'fuzz_chaos' + }; + for (const [job, gate] of Object.entries(gates)) { + assert.equal(enabled(job, { action: 'opened', outputs: { [gate]: 'false' } }), false, job); + assert.equal(enabled(job, { action: 'opened', outputs: {} }), true, job); + assert.equal(enabled(job, { outputs: { [gate]: 'true' } }), false, job); + assert.equal(enabled(job, { action: 'opened', cancelled: true }), false, job); + } + for (const job of suites) { + for (const result of ['failure', 'skipped', 'cancelled']) { + assert.equal(enabled(job, { action: 'opened', results: { 'prepare-fuzz-targets': result } }), false, job); + const build = job === 'fuzztest' ? 'build' : 'build-greptime-ci'; + assert.equal(enabled(job, { action: 'opened', results: { [build]: result } }), false, job); + } + } +}); + +test('script tests must pass before any expensive integration job can start', () => { + const jobs = ['build', 'sqlness', 'sqlness-kvbackend', 'export-import-v2-e2e', + 'run-multi-lang-tests', 'compat', ...prerequisites, ...suites]; + for (const job of jobs) { + assert.equal(enabled(job, { action: 'opened' }), true, job); + for (const result of ['failure', 'skipped', 'cancelled']) { + assert.equal(enabled(job, { action: 'opened', results: { 'github-script-tests': result } }), false, job); + } + } +}); + +test('script tests gate expensive Rust and Checks jobs', () => { + for (const [file, jobs] of [ + ['rust.yml', ['clippy', 'check-riscv64', 'check-windows', 'test', 'coverage']], + ['checks.yml', ['check']] + ]) { + const workflow = readFileSync(resolve(workflows, file), 'utf8'); + for (const job of jobs) { + const event = job === 'coverage' ? 'merge_group' : 'workflow_dispatch'; + assert.equal(enabled(job, { workflow, event }), true, job); + for (const result of ['failure', 'skipped', 'cancelled']) { + assert.equal(enabled(job, { workflow, event, results: { 'github-script-tests': result } }), false, job); + } + } + } +}); + +test('platform compilation runs only on schedule or manual dispatch', () => { + const workflow = readFileSync(resolve(workflows, 'rust.yml'), 'utf8'); + for (const job of ['check-windows', 'check-riscv64']) { + for (const event of ['pull_request', 'merge_group']) { + assert.equal(enabled(job, { workflow, event, action: 'opened' }), false, job); + } + for (const event of ['schedule', 'workflow_dispatch']) { + assert.equal(enabled(job, { workflow, event }), true, job); + assert.equal(enabled(job, { workflow, event, repository: 'fork/greptimedb' }), false, job); + } + } +}); diff --git a/.github/scripts/tests/test_ci_slash.py b/.github/scripts/tests/test_ci_slash.py index c8c01ceda15..2d9c7bccc84 100644 --- a/.github/scripts/tests/test_ci_slash.py +++ b/.github/scripts/tests/test_ci_slash.py @@ -136,7 +136,7 @@ class CiSlashTest(unittest.TestCase): def test_fork_rerun_gates_cover_standard_jobs(self): import re - for name, count in [("rust", 7), ("integration", 5), ("checks", 5), ("docs", 3)]: + for name, count in [("rust", 6), ("integration", 8), ("checks", 6), ("docs", 3)]: workflow = (SCRIPT.parents[1] / "workflows" / (name + ".yml")).read_text() gates = re.findall(r"^ if:.*github.run_attempt > 1.*$", workflow, re.MULTILINE) self.assertEqual(len(gates), count, name) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 3bc54372d06..1f14d8851cc 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -71,44 +71,28 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: ./.github/actions/github-script-tests + + scope-rules: + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + name: CI Scope Rules + # Same runner image as the `scope` job in integration.yml: if the rules file + # stops parsing there, the gate silently runs every job, so this check exists + # to fail the pull request instead. + runs-on: ubuntu-slim + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 with: - node-version: 24 - - name: Run Node GitHub script tests - run: node --test .github/scripts/query-regression-comment.test.cjs - - name: Setup uv - uses: astral-sh/setup-uv@v3 - with: - version: "latest" - - name: Run check-version script tests - run: uv run --no-project python .github/scripts/check-version-test.py - - name: Run release Linux architecture tests - run: python3 .github/scripts/release-linux-architectures-test.py - - name: Run previous-release-tag script tests - run: python3 .github/scripts/previous-release-tag-test.py - - name: Run enterprise license check tests - run: python3 scripts/check-enterprise-license-test.py - - name: Run fuzz orchestration script tests - run: .github/scripts/run-fuzz-targets-test.sh - - name: Run CI comment-command tests - run: python3 .github/scripts/tests/test_ci_slash.py - - name: Run metric snapshot benchmark tests - run: python3 -m unittest discover -s .github/scripts/tests -p 'test_metric_snapshot*.py' - - name: Run query-regression Python tooling tests - run: | - python3 tests/perf/test_query_regression_runner_compaction_toctou.py - python3 tests/perf/test_query_regression_runner_otlp_trace_load.py - python3 tests/perf/test_query_regression_summary_otlp.py - python3 tests/perf/test_query_regression_case_selection.py - python3 tests/perf/test_query_regression_nightly_refs.py - python3 tests/perf/test_query_regression_slash.py - python3 tests/perf/test_aliyun_ecs_runner_scripts.py - python3 tests/perf/test_ci_runner_scripts.py - python3 tests/perf/test_agent_observability_summary.py - python3 tests/perf/test_tracesbench_summary.py + persist-credentials: false + - name: Show the interpreter + run: python3 -VV + - name: Validate .github/ci/scope-rules.toml + run: python3 .github/ci/ci_scope_rules.py --check-paths check: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + needs: [github-script-tests] name: Check runs-on: ${{ matrix.os }} strategy: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index afce4a8c796..00c3f52a034 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -21,7 +21,7 @@ on: default: false type: boolean fuzz_profile: - description: Fuzz profile selected by /ci fuzz + description: Fuzz profile for a manual run or /ci fuzz required: false default: none type: choice @@ -42,8 +42,71 @@ concurrency: cancel-in-progress: true jobs: - build: + github-script-tests: if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + name: GitHub Script Tests (Integration CI) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ./.github/actions/github-script-tests + + # Which jobs this gate skips, and why: .github/ci/README.md. + scope: + name: CI Scope + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + # ubuntu-slim ships python3, git and curl; this job only classifies. + runs-on: ubuntu-slim + timeout-minutes: 10 + permissions: + contents: read + # `/pulls/{n}` and `/pulls/{n}/files`. An explicit block sets every + # unlisted permission to none, and a 403 here would silently widen every + # gate instead of classifying. + pull-requests: read + outputs: + any_fuzz: ${{ steps.classify.outputs.any_fuzz }} + build_ci_needed: ${{ steps.classify.outputs.build_ci_needed }} + fuzz_standalone: ${{ steps.classify.outputs.fuzz_standalone }} + fuzz_distributed: ${{ steps.classify.outputs.fuzz_distributed }} + fuzz_chaos: ${{ steps.classify.outputs.fuzz_chaos }} + fuzz_unstable: ${{ steps.classify.outputs.fuzz_unstable }} + export_import_v2: ${{ steps.classify.outputs.export_import_v2 }} + sqlness_kvbackend: ${{ steps.classify.outputs.sqlness_kvbackend }} + steps: + # Rules come from the pull request base (the queue base on merge_group), + # so a pull request cannot weaken its own gate. If this job or the Classify + # step fails, the outputs stay empty and every gate below treats an absent + # value as "run", which keeps CI fail-open. + - uses: actions/checkout@v4 + # A checkout failure leaves the outputs empty, which every gate treats + # as "run"; letting the step fail would skip the gates instead. + continue-on-error: true + with: + ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.sha }} + persist-credentials: false + - name: Classify + id: classify + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CI_SCOPE_FORCE_ALL: ${{ vars.CI_SCOPE_FORCE_ALL || 'false' }} + run: | + args=(--repo "$GITHUB_REPOSITORY" --event "$GITHUB_EVENT_NAME" + --github-output "$GITHUB_OUTPUT" + --summary "$GITHUB_STEP_SUMMARY") + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + args+=(--pr "${{ github.event.pull_request.number }}" + --head-sha "${{ github.event.pull_request.head.sha }}" + --base-sha "${{ github.event.pull_request.base.sha }}") + fi + python3 .github/ci/ci_scope.py "${args[@]}" + + build: + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + needs: [github-script-tests] name: Build GreptimeDB binaries runs-on: ${{ matrix.os }} strategy: @@ -104,9 +167,9 @@ jobs: version: current sqlness: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Sqlness Test (${{ matrix.mode.name }}) - needs: build + needs: [github-script-tests, build] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -116,28 +179,12 @@ jobs: - name: "Basic" opts: "" kafka: false - postgres: false - mysql: false - name: "Remote WAL" opts: "-w kafka -k 127.0.0.1:9092" kafka: true - postgres: false - mysql: false - - name: "PostgreSQL KvBackend" - opts: "--setup-pg postgresql://greptimedb:admin@127.0.0.1:5432/postgres" - kafka: false - postgres: true - mysql: false - - name: "MySQL KvBackend" - opts: "--setup-mysql mysql://greptimedb:admin@127.0.0.1:3306/mysql" - kafka: false - postgres: false - mysql: true - name: "Flat format" opts: "--enable-flat-format" kafka: false - postgres: false - mysql: false timeout-minutes: 60 steps: - uses: actions/checkout@v4 @@ -149,16 +196,6 @@ jobs: working-directory: tests-integration/fixtures run: ../../.github/scripts/pull-test-deps-images.sh && docker compose up -d --wait kafka - - if: matrix.mode.postgres - name: Setup PostgreSQL - working-directory: tests-integration/fixtures - run: ../../.github/scripts/pull-test-deps-images.sh && docker compose up -d --wait postgres - - - if: matrix.mode.mysql - name: Setup MySQL - working-directory: tests-integration/fixtures - run: ../../.github/scripts/pull-test-deps-images.sh && docker compose up -d --wait mysql - - name: Download pre-built binaries uses: actions/download-artifact@v4 with: @@ -183,10 +220,68 @@ jobs: /tmp/sqlness* retention-days: 3 + sqlness-kvbackend: + # The two foreign KV backends are the only sqlness legs that are not + # required checks, so they are the only ones this gate may skip. They run + # the same cases as the `Basic` leg, so the diff only matters when it can + # reach the backend itself; the merge queue runs them regardless. + if: ${{ !cancelled() && needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) && needs.build.result == 'success' && needs.scope.outputs.sqlness_kvbackend != 'false' }} + name: Sqlness Test (${{ matrix.mode.name }}) + needs: [github-script-tests, scope, build] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + mode: + - name: "Metasrv on PostgreSQL" + id: "metasrv-postgres" + opts: "--setup-pg postgresql://greptimedb:admin@127.0.0.1:5432/postgres" + postgres: true + mysql: false + - name: "Metasrv on MySQL" + id: "metasrv-mysql" + opts: "--setup-mysql mysql://greptimedb:admin@127.0.0.1:3306/mysql" + postgres: false + mysql: true + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - if: matrix.mode.postgres + name: Setup PostgreSQL + working-directory: tests-integration/fixtures + run: ../../.github/scripts/pull-test-deps-images.sh && docker compose up -d --wait postgres + + - if: matrix.mode.mysql + name: Setup MySQL + working-directory: tests-integration/fixtures + run: ../../.github/scripts/pull-test-deps-images.sh && docker compose up -d --wait mysql + + - name: Download pre-built binaries + uses: actions/download-artifact@v4 + with: + name: bins + path: . + - name: Unzip binaries + run: tar -xvf ./bins.tar.gz + - name: Run sqlness + run: RUST_BACKTRACE=1 ./bins/sqlness-runner bare ${{ matrix.mode.opts }} -c ./tests/cases --bins-dir ./bins --preserve-state + - name: Upload sqlness logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: sqlness-logs-${{ matrix.mode.name }} + path: | + /tmp/sqlness* + retention-days: 3 + export-import-v2-e2e: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + if: ${{ !cancelled() && needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) && needs.build.result == 'success' && needs.scope.outputs.export_import_v2 != 'false' }} name: Export/Import V2 E2E Test - needs: build + needs: [github-script-tests, scope, build] runs-on: ubuntu-latest timeout-minutes: 60 steps: @@ -254,9 +349,9 @@ jobs: retention-days: 3 run-multi-lang-tests: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Run Multi-language SDK Tests - needs: build + needs: [github-script-tests, build] uses: ./.github/workflows/run-multi-lang-tests.yml with: artifact-name: bins @@ -279,9 +374,9 @@ jobs: run: python3 .github/scripts/update-compat-versions.py --check --published-only --check-anchors compat: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || (github.event_name == 'pull_request' && (github.event.action == 'ready_for_review' || (github.event.action == 'opened' && github.event.pull_request.draft == false)))) }} + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || (github.event_name == 'pull_request' && (github.event.action == 'ready_for_review' || (github.event.action == 'opened' && github.event.pull_request.draft == false)))) }} name: Compatibility Test (recent releases) - needs: build + needs: [github-script-tests, build] runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -318,9 +413,12 @@ jobs: path: /tmp/sqlness-compat* retention-days: 3 + # Fuzz runs on opening a ready PR, marking it ready, manual dispatch, and + # the scheduled main run. Synchronize events keep the core integration tests. prepare-fuzz-targets: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && ((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'workflow_dispatch' && inputs.ci_command)) && (github.event_name == 'pull_request' || inputs.fuzz_profile != 'none') }} + if: ${{ !cancelled() && needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'schedule' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false && (github.event.action == 'opened' || github.event.action == 'ready_for_review')) || (github.event_name == 'workflow_dispatch' && (inputs.fuzz_profile != 'none'))) && needs.scope.outputs.any_fuzz != 'false' }} name: Prepare Fuzz Targets + needs: [github-script-tests, scope] runs-on: ubuntu-latest timeout-minutes: 60 steps: @@ -433,9 +531,9 @@ jobs: if-no-files-found: error fuzztest: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && ((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'workflow_dispatch' && inputs.ci_command)) && (github.event_name == 'pull_request' || inputs.fuzz_profile == 'standalone' || inputs.fuzz_profile == 'all') }} + if: ${{ !cancelled() && needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'schedule' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false && (github.event.action == 'opened' || github.event.action == 'ready_for_review')) || (github.event_name == 'workflow_dispatch' && (inputs.fuzz_profile == 'standalone' || inputs.fuzz_profile == 'all'))) && needs.build.result == 'success' && needs['prepare-fuzz-targets'].result == 'success' && needs.scope.outputs.fuzz_standalone != 'false' }} name: Fuzz Test (Standalone, ${{ matrix.group.name }}) - needs: [build, prepare-fuzz-targets] + needs: [github-script-tests, build, prepare-fuzz-targets, scope] runs-on: ubuntu-latest timeout-minutes: 60 strategy: @@ -514,9 +612,9 @@ jobs: artifact-name: fuzz-standalone-${{ matrix.group.id }} unstable-fuzztest: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && ((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'workflow_dispatch' && inputs.ci_command)) && (github.event_name == 'pull_request' || inputs.fuzz_profile == 'standalone' || inputs.fuzz_profile == 'all') }} + if: ${{ !cancelled() && needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'schedule' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false && (github.event.action == 'opened' || github.event.action == 'ready_for_review')) || (github.event_name == 'workflow_dispatch' && (inputs.fuzz_profile == 'standalone' || inputs.fuzz_profile == 'all'))) && needs['build-greptime-ci'].result == 'success' && needs['prepare-fuzz-targets'].result == 'success' && needs.scope.outputs.fuzz_unstable != 'false' }} name: Unstable Fuzz Test - needs: [build-greptime-ci, prepare-fuzz-targets] + needs: [github-script-tests, build-greptime-ci, prepare-fuzz-targets, scope] runs-on: ubuntu-latest timeout-minutes: 60 steps: @@ -574,8 +672,9 @@ jobs: artifact-name: fuzz-unstable-standalone build-greptime-ci: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && ((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'workflow_dispatch' && inputs.ci_command && inputs.fuzz_profile != 'none')) }} + if: ${{ !cancelled() && needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'schedule' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false && (github.event.action == 'opened' || github.event.action == 'ready_for_review')) || (github.event_name == 'workflow_dispatch' && (inputs.fuzz_profile != 'none'))) && needs.scope.outputs.build_ci_needed != 'false' }} name: Build GreptimeDB binary (profile-CI) + needs: [github-script-tests, scope] runs-on: ${{ matrix.os }} strategy: matrix: @@ -634,10 +733,10 @@ jobs: version: current distributed-fuzztest: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && ((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'workflow_dispatch' && inputs.ci_command)) && (github.event_name == 'pull_request' || inputs.fuzz_profile == 'distributed' || inputs.fuzz_profile == 'all') }} + if: ${{ !cancelled() && needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'schedule' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false && (github.event.action == 'opened' || github.event.action == 'ready_for_review')) || (github.event_name == 'workflow_dispatch' && (inputs.fuzz_profile == 'distributed' || inputs.fuzz_profile == 'all'))) && needs['build-greptime-ci'].result == 'success' && needs['prepare-fuzz-targets'].result == 'success' && needs.scope.outputs.fuzz_distributed != 'false' }} name: Fuzz Test (Distributed, ${{ matrix.mode.name }}, ${{ matrix.group.name }}) runs-on: ubuntu-latest - needs: [build-greptime-ci, prepare-fuzz-targets] + needs: [github-script-tests, build-greptime-ci, prepare-fuzz-targets, scope] timeout-minutes: 60 strategy: fail-fast: false @@ -873,10 +972,10 @@ jobs: docker system prune -f distributed-fuzztest-with-chaos: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && ((github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'workflow_dispatch' && inputs.ci_command)) && (github.event_name == 'pull_request' || inputs.fuzz_profile == 'chaos' || inputs.fuzz_profile == 'all') }} + if: ${{ !cancelled() && needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'schedule' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false && (github.event.action == 'opened' || github.event.action == 'ready_for_review')) || (github.event_name == 'workflow_dispatch' && (inputs.fuzz_profile == 'chaos' || inputs.fuzz_profile == 'all'))) && needs['build-greptime-ci'].result == 'success' && needs['prepare-fuzz-targets'].result == 'success' && needs.scope.outputs.fuzz_chaos != 'false' }} name: Fuzz Test with Chaos (Distributed, ${{ matrix.mode.name }}, ${{ matrix.group.name }}) runs-on: ubuntu-latest - needs: [build-greptime-ci, prepare-fuzz-targets] + needs: [github-script-tests, build-greptime-ci, prepare-fuzz-targets, scope] timeout-minutes: 60 strategy: fail-fast: false diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d1b93a60e43..842fa306c4e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -31,6 +31,17 @@ concurrency: cancel-in-progress: true jobs: + github-script-tests: + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + name: GitHub Script Tests (Rust CI) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ./.github/actions/github-script-tests + fmt: if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Rustfmt @@ -50,7 +61,8 @@ jobs: run: make fmt-check clippy: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + needs: [github-script-tests] name: Clippy runs-on: ubuntu-latest timeout-minutes: 60 @@ -94,7 +106,9 @@ jobs: run: make check-udeps check-riscv64: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + # Keep platform coverage on the weekday schedule and allow manual checks. + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} + needs: [github-script-tests] name: Check riscv64 build runs-on: ubuntu-latest timeout-minutes: 90 @@ -120,7 +134,9 @@ jobs: run: cargo check --locked --workspace --all-targets --features servers/dashboard --target riscv64gc-unknown-linux-gnu check-windows: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + # Keep platform coverage on the weekday schedule and allow manual checks. + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} + needs: [github-script-tests] name: Check Windows build runs-on: windows-2022 timeout-minutes: 60 @@ -148,48 +164,6 @@ jobs: env: RUSTFLAGS: "-D warnings" - test-windows: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} - name: Test Windows - runs-on: windows-2022 - timeout-minutes: 90 - env: - CARGO_INCREMENTAL: 0 - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 - RUSTFLAGS: "-D warnings -C linker=lld-link" - RUST_BACKTRACE: 1 - RUST_MIN_STACK: 8388608 # 8MB - steps: - - run: git config --global core.autocrlf false - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: ./.github/actions/setup-cyborg - - uses: taiki-e/install-action@v2 - with: - tool: protoc@3.23.4 - - uses: KyleMayes/install-llvm-action@v1 - with: - version: "14.0" - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - cache: false - - name: Rust Cache - uses: Swatinem/rust-cache@v2 - with: - shared-key: "test-windows" - cache-all-crates: "true" - save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Install Cargo Nextest - uses: taiki-e/install-action@nextest - - name: Install WSL distribution - uses: Vampire/setup-wsl@v2 - with: - distribution: Ubuntu-22.04 - - name: Run Windows tests - run: cargo nextest run --locked --workspace -F dashboard --no-fail-fast - conflict-check: if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Check for conflict @@ -202,10 +176,10 @@ jobs: uses: olivernybroe/action-conflict-finder@v4.0 test: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && github.event_name != 'merge_group' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && github.event_name != 'merge_group' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} runs-on: ubuntu-22.04-arm timeout-minutes: 60 - needs: [conflict-check, clippy, fmt, check-udeps] + needs: [github-script-tests, conflict-check, clippy, fmt, check-udeps] steps: - uses: actions/checkout@v4 with: @@ -258,7 +232,8 @@ jobs: UNITTEST_LOG_DIR: "__unittest_logs" coverage: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && github.event_name == 'merge_group' }} + if: ${{ needs['github-script-tests'].result == 'success' && github.repository == 'GreptimeTeam/greptimedb' && github.event_name == 'merge_group' }} + needs: [github-script-tests] runs-on: ubuntu-22.04-8-cores timeout-minutes: 60 steps: diff --git a/Makefile b/Makefile index 21c87cc3707..073a95cb1fa 100644 --- a/Makefile +++ b/Makefile @@ -249,6 +249,10 @@ fmt-check: ## Check code format. check-enterprise-license: ## Check enterprise-gated files are listed in both license configs. python3 scripts/check-enterprise-license.py +.PHONY: check-ci-scope +check-ci-scope: ## Check the CI scope rules still match existing paths. + python3 .github/ci/ci_scope_rules.py --check-paths + .PHONY: start-etcd start-etcd: ## Start single node etcd for testing purpose. docker run --rm -d --network=host -p 2379-2380:2379-2380 ${ETCD_IMAGE} diff --git a/tests/compatibility/ci.toml b/tests/compatibility/ci.toml index e56d67a26f2..2e89680c3f3 100644 --- a/tests/compatibility/ci.toml +++ b/tests/compatibility/ci.toml @@ -6,7 +6,7 @@ # exercised by nightly runs (--nightly-window); they are not retained in the PR # window. Run .github/scripts/update-compat-versions.py --update --published-only # after release tags land. -from_versions = ["v1.1.4", "v1.2.1"] +from_versions = ["v1.2.1", "v1.3.0"] # Recent releases that must be able to reopen tables written by the PR build. downgrade_to_versions = ["v1.1.4"]