Skip to content

Commit ef870da

Browse files
authored
Validate main promotion: Prow includedBranches + periodic current-release, require release-X disabled only when repo has main/master (#76343)
1 parent 8897316 commit ef870da

2 files changed

Lines changed: 194 additions & 2 deletions

File tree

Makefile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
SHELL=/usr/bin/env bash -o errexit
22

3-
.PHONY: help check check-boskos check-core check-services dry-core core dry-services services all update release-controllers checkconfig jobs ci-operator-config registry-metadata boskos-config prow-config validate-step-registry new-repo branch-cut prow-config multi-arch-gen
3+
.PHONY: help check check-boskos check-core check-services check-validate-main-promotion dry-core core dry-services services all update release-controllers checkconfig jobs ci-operator-config registry-metadata boskos-config prow-config validate-step-registry new-repo branch-cut prow-config multi-arch-gen
44

55
export CONTAINER_ENGINE ?= podman
66
export CONTAINER_ENGINE_OPTS ?= --platform linux/amd64
@@ -22,7 +22,7 @@ help:
2222

2323
all: core services
2424

25-
check: check-core check-services check-boskos check-labels check-cluster-profiles check-yaml-indentation
25+
check: check-core check-services check-boskos check-labels check-cluster-profiles check-yaml-indentation check-validate-main-promotion
2626
@echo "Service config check: PASS"
2727

2828
check-boskos:
@@ -41,6 +41,10 @@ check-yaml-indentation: python-help
4141
hack/validate-yaml-indentation.sh .
4242
@echo "YAML indentation check: PASS"
4343

44+
check-validate-main-promotion: python-help
45+
python3 hack/validate-main-promotion-guard.py
46+
@echo "Main promotion validation: PASS"
47+
4448
check-core:
4549
core-services/_hack/validate-core-services.sh core-services
4650
@echo "Core service config check: PASS"
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#!/usr/bin/env python3
2+
3+
import os
4+
import re
5+
import sys
6+
from pathlib import Path
7+
8+
import yaml
9+
10+
REPO_ROOT = Path(__file__).resolve().parent.parent
11+
CONFIG_DIR = REPO_ROOT / "ci-operator" / "config"
12+
PROW_CONFIG_DIR = REPO_ROOT / "core-services" / "prow" / "02_config"
13+
INFRA_PERIODICS = REPO_ROOT / "ci-operator" / "jobs" / "infra-periodics.yaml"
14+
AUTO_CONFIG_BRANCHER_JOB = "periodic-prow-auto-config-brancher"
15+
CURRENT_RELEASE_ARG_RE = re.compile(r"^--current-release=(.+)$")
16+
17+
# Repos whose main/master may promote to a release other than current (e.g. gatekeeper 4.6). cri-o is not listed.
18+
MAIN_PROMOTION_IGNORE = {
19+
"openshift/gatekeeper",
20+
"openshift/gatekeeper-operator",
21+
"openshift-priv/gatekeeper",
22+
"openshift-priv/gatekeeper-operator",
23+
"openshift/network.offline_migration_sdn_to_ovnk",
24+
"openshift-priv/network.offline_migration_sdn_to_ovnk",
25+
"openshift-pipelines/console-plugin",
26+
"kubev2v/migration-planner",
27+
"kubev2v/migration-planner-ui-app",
28+
"openshift-online/ocm-cluster-service",
29+
}
30+
31+
32+
def get_current_release_from_auto_config_brancher():
33+
"""Read current-release from periodic-prow-auto-config-brancher (same source as config-brancher)."""
34+
if not INFRA_PERIODICS.is_file():
35+
return None, None
36+
try:
37+
with open(INFRA_PERIODICS, encoding="utf-8") as f:
38+
data = yaml.safe_load(f)
39+
except (OSError, yaml.YAMLError):
40+
return None, None
41+
periodics = data.get("periodics") or []
42+
for job in periodics:
43+
if job.get("name") != AUTO_CONFIG_BRANCHER_JOB:
44+
continue
45+
spec = job.get("spec") or {}
46+
containers = spec.get("containers") or []
47+
for c in containers:
48+
for arg in c.get("args") or []:
49+
m = CURRENT_RELEASE_ARG_RE.match(str(arg).strip())
50+
if m:
51+
return m.group(1).strip(), INFRA_PERIODICS
52+
return None, None
53+
54+
55+
def _load_prow_tide_queries(org: str, repo: str):
56+
"""Return list of tide queries from _prowconfig.yaml, or None if file missing/unreadable."""
57+
path = PROW_CONFIG_DIR / org / repo / "_prowconfig.yaml"
58+
if not path.is_file():
59+
return None
60+
try:
61+
with open(path, encoding="utf-8") as f:
62+
data = yaml.safe_load(f) or {}
63+
except (OSError, yaml.YAMLError):
64+
return None
65+
return (data.get("tide") or {}).get("queries") or []
66+
67+
68+
def has_current_release_branch_in_prow(org: str, repo: str, current_release: str) -> bool:
69+
"""True if repo has openshift-{current_release} or release-{current_release} in includedBranches (development branch)."""
70+
queries = _load_prow_tide_queries(org, repo)
71+
if queries is None or not queries:
72+
return False
73+
want = {f"openshift-{current_release}", f"release-{current_release}"}
74+
for q in queries:
75+
included = q.get("includedBranches") or []
76+
if any(str(x).strip() in want for x in included):
77+
return True
78+
return False
79+
80+
81+
def load_config():
82+
allowed, source_file = get_current_release_from_auto_config_brancher()
83+
if not allowed:
84+
print(
85+
f"ERROR: Could not determine current release. Set --current-release in {AUTO_CONFIG_BRANCHER_JOB} in {INFRA_PERIODICS.relative_to(REPO_ROOT)}.",
86+
file=sys.stderr,
87+
)
88+
sys.exit(2)
89+
allowed_priv = f"{allowed}-priv"
90+
return allowed, allowed_priv, source_file
91+
92+
93+
def get_promotion_targets(config_path):
94+
try:
95+
with open(config_path, encoding="utf-8") as f:
96+
data = yaml.safe_load(f)
97+
except (OSError, yaml.YAMLError) as e:
98+
return None, str(e)
99+
promotion = data.get("promotion") or {}
100+
to_list = promotion.get("to") or []
101+
result = []
102+
for entry in to_list:
103+
if not isinstance(entry, dict):
104+
continue
105+
name = entry.get("name")
106+
if name is None:
107+
continue
108+
namespace = entry.get("namespace") or "ocp"
109+
disabled = entry.get("disabled") is True
110+
result.append((str(name).strip('"'), namespace, disabled))
111+
return result, None
112+
113+
114+
def is_promotion_fully_disabled(config_path):
115+
"""True if promotion is absent or all promotion.to entries have disabled: true."""
116+
try:
117+
with open(config_path, encoding="utf-8") as f:
118+
data = yaml.safe_load(f)
119+
except (OSError, yaml.YAMLError):
120+
return False
121+
to_list = (data.get("promotion") or {}).get("to") or []
122+
if not to_list:
123+
return True
124+
return all(entry.get("disabled") is True for entry in to_list if isinstance(entry, dict))
125+
126+
127+
def main():
128+
allowed_ocp, allowed_priv, source_file = load_config()
129+
130+
violations = []
131+
release_branch_suffixes = (f"-release-{allowed_ocp}.yaml", f"-openshift-{allowed_ocp}.yaml")
132+
133+
for org_dir in CONFIG_DIR.iterdir():
134+
if not org_dir.is_dir():
135+
continue
136+
org = org_dir.name
137+
for root, _dirs, files in os.walk(org_dir):
138+
try:
139+
path_rel = Path(root).relative_to(CONFIG_DIR)
140+
parts = path_rel.parts
141+
repo = parts[1] if len(parts) >= 2 else ""
142+
except ValueError:
143+
continue
144+
in_scope_prow = has_current_release_branch_in_prow(org, repo, allowed_ocp)
145+
for f in files:
146+
if "__" in f:
147+
continue
148+
path = Path(root) / f
149+
rel_path = path.relative_to(REPO_ROOT)
150+
if f.endswith("-main.yaml") or f.endswith("-master.yaml"):
151+
try:
152+
org_repo = str(path_rel)
153+
except ValueError:
154+
org_repo = ""
155+
if org_repo in MAIN_PROMOTION_IGNORE:
156+
continue
157+
targets, err = get_promotion_targets(path)
158+
if err:
159+
violations.append((str(rel_path), f"Failed to parse: {err}"))
160+
continue
161+
for name, namespace, disabled in targets:
162+
if disabled:
163+
continue
164+
if namespace == "ocp" and name != allowed_ocp:
165+
violations.append((str(rel_path), f"promotes to {namespace}/{name} (main/master must only promote to {allowed_ocp})"))
166+
if namespace == "ocp-private" and name != allowed_priv:
167+
violations.append((str(rel_path), f"promotes to {namespace}/{name} (main/master must only promote to {allowed_priv})"))
168+
continue
169+
if in_scope_prow and any(f.endswith(s) for s in release_branch_suffixes):
170+
has_main_or_master = any(
171+
x.endswith("-main.yaml") or x.endswith("-master.yaml") for x in files
172+
)
173+
if has_main_or_master and not is_promotion_fully_disabled(path):
174+
violations.append((str(rel_path), f"release/openshift-{allowed_ocp} config must have promotion disabled (only main/master promote to {allowed_ocp})"))
175+
176+
if violations:
177+
print(f"ERROR: Main/master must promote to current release only; release-{allowed_ocp} configs must have promotion disabled.", file=sys.stderr)
178+
print(f" main/master -> ocp/{allowed_ocp}, ocp-private/{allowed_priv}. release-{allowed_ocp} / openshift-{allowed_ocp} -> promotion disabled.", file=sys.stderr)
179+
rel = source_file.relative_to(REPO_ROOT)
180+
print(f" Current release from {rel}. Main/master: all repos in ci-operator/config (same as config-brancher). Release-X disabled: only when _prowconfig has openshift-{allowed_ocp} or release-{allowed_ocp} in includedBranches.", file=sys.stderr)
181+
print("", file=sys.stderr)
182+
for path, msg in violations:
183+
print(f" {path}: {msg}", file=sys.stderr)
184+
sys.exit(1)
185+
186+
187+
if __name__ == "__main__":
188+
main()

0 commit comments

Comments
 (0)