mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-11-06 19:06:58 +00:00
* ci: gate the integration CI long tail by diff scope `integration.yml` starts its long tail on every pull request: the three fuzz families, the chaos legs, the export/import E2E job and the two sqlness KvBackend legs. Most diffs cannot affect them, so a `scope` job now classifies the pull request and tells those jobs whether to start. The classifier is deterministic and fail-open. Forced modes (merge queue, `schedule`, `workflow_dispatch`, `ci:full`, `CI_SCOPE_FORCE_ALL`) and three floors -- always-run paths, an uncovered `src/` change, and a PromQL/flow filter -- can only widen the result; everything else is a glob match against the suite catalog. Every step of the job is `continue-on-error` and every gate reads `needs.scope.outputs.<key> != 'false'`, so a classifier crash runs the jobs instead of skipping them. The rules are checked out from the pull request base, so a pull request cannot relax its own gate. The policy is data: `.github/ci/scope-rules.toml` holds the path groups, the suite catalog and the always-run list, with a `reason` on every exclusion. `ci_scope_rules.py` loads it with the stdlib `tomllib` and validates the shape (unknown or cyclic `@group` references, a `family` that disagrees with the suite id, an empty reason) and that every glob's literal prefix still exists, so a renamed crate cannot leave a rule that matches nothing. `make check-ci-scope` runs that check locally; the `CI Scope Rules` job runs it on `ubuntu-slim`, the same image the classifier uses. `.github/ci/scope-cases.toml` holds golden cases that `test_ci_scope.py` replays through the real classifier. The PostgreSQL and MySQL sqlness legs move into their own `sqlness-kvbackend` job so they can be gated: a job-level `if:` cannot read the matrix, and gating them inside `sqlness` would skip required checks and block the merge. They run the same cases as the required Basic leg, so only the backend itself matters. The verdict is visible where triage looks: one log line per suite, the exact gate values, `::warning::` when a lookup fails and the gate widens, and a job summary that lists each suite with the rule that decided it and the changed files that matched. Measured on 200 merged and open pull requests: 27.5% always run, 41% decided by path rules, 31.5% skip every gated job; 995 of 2600 suite selections are skipped, with no selection that a path rule makes. The classifier itself runs in about ten seconds on a single-vCPU runner. Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): let the scope job read pull requests The `scope` job declared `permissions: contents: read`, and an explicit block sets every unlisted permission to `none`. `GET /pulls/{n}` therefore answered 403, `build_plan` widened to a full run as designed, and every gate read an absent output as "run": the gate could never skip anything. - `pull-requests: read` on the `scope` job. - `tests/runner/**` joins `@kv-backend`: the runner implements `--setup-pg`/`--setup-mysql`, which only the KV-backend legs pass, so a change there must not skip the only job that exercises it. The `UNMAPPED_CRATES` excuse is gone with it. - The README no longer claims a queue failure blocks the merge: the gated jobs are not required checks. Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): run every suite when the kind registry script changes The distributed and chaos fuzz jobs start their cluster through `.github/actions/setup-kind`, which runs `.github/scripts/kind-with-registry.sh`. `.github/actions/**` is in `ALWAYS_RUN` but the script is not, so a change confined to it selected no suite at all and every gate stayed `false`: the jobs that exercise the changed cluster setup were skipped. The script joins `ALWAYS_RUN`, consistent with the action that calls it, and a golden case pins it. Signed-off-by: WenyXu <wenymedia@gmail.com> * fix(ci): widen the gate for the fuzz corpus seeding script `fuzz-test/action.yaml` runs `.github/scripts/prepare-fuzz-corpus.sh` before the cache restore in every fuzz job, but the script was not in `ALWAYS_RUN`. A change confined to it therefore selected no suite: `any_fuzz` stayed `false` and every fuzz job that runs it was skipped. The script joins `ALWAYS_RUN` next to the other scripts the fuzz jobs execute, with a golden case for it. Both misses so far (this one and `kind-with-registry.sh`) came through a composite action, which the workflow never names. `WorkflowContractTests` now resolves `uses: ./.github/actions/<name>` transitively for the gated jobs and fails when one of the scripts they run is not in `ALWAYS_RUN`. Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com>
278 lines
9.6 KiB
Python
Executable File
278 lines
9.6 KiB
Python
Executable File
#!/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())
|