@misterhuydo/sentinel 1.4.68 → 1.4.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ """Patch sentinel_boss.py to add chain_release tool."""
3
+ import sys
4
+
5
+ TARGET = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
6
+
7
+ with open(TARGET, 'r', encoding='utf-8') as f:
8
+ content = f.read()
9
+
10
+ # ── 1. Add tool definition (before the closing ] of the tools list) ──────────
11
+
12
+ TOOL_DEF = ''' {
13
+ "name": "chain_release",
14
+ "description": (
15
+ "Execute a sequential release chain: release repo A, update repo B's dependency on A and release B, "
16
+ "update repo C's dependency on B and release C, and so on. "
17
+ "Use when the admin describes a multi-step release pipeline like "
18
+ "'release TypeLib, then update Java-SDK with new TypeLib and release it, then update Admin-SDK...'. "
19
+ "Also handles shorter requests like 'release TypeLib and cascade' by inferring the full chain. "
20
+ "confirmed=false shows the full plan with version numbers; confirmed=true executes all steps in order."
21
+ ),
22
+ "input_schema": {
23
+ "type": "object",
24
+ "properties": {
25
+ "chain": {
26
+ "type": "array",
27
+ "items": {"type": "string"},
28
+ "description": (
29
+ "Ordered list of repo names from first-to-release to last. "
30
+ "E.g. ['Whydah-TypeLib', 'Whydah-Java-SDK', 'Whydah-Admin-SDK', '1881-SSOLoginWebApp']"
31
+ ),
32
+ },
33
+ "confirmed": {
34
+ "type": "boolean",
35
+ "description": "false = show plan only (default); true = execute all steps in sequence",
36
+ },
37
+ },
38
+ "required": ["chain", "confirmed"],
39
+ },
40
+ },
41
+ ]'''
42
+
43
+ OLD_CLOSE = ''' },
44
+ ]
45
+
46
+
47
+ # ── Workspace helpers'''
48
+
49
+ NEW_CLOSE = TOOL_DEF + '''
50
+
51
+
52
+ # ── Workspace helpers'''
53
+
54
+ if OLD_CLOSE not in content:
55
+ print("ERROR: tools list closing marker not found")
56
+ sys.exit(1)
57
+
58
+ content = content.replace(OLD_CLOSE, NEW_CLOSE, 1)
59
+
60
+ # ── 2. Add chain_release to _ADMIN_TOOLS ─────────────────────────────────────
61
+
62
+ OLD_ADMIN = '"manage_release"}'
63
+ NEW_ADMIN = '"manage_release", "chain_release"}'
64
+ if OLD_ADMIN not in content:
65
+ print("ERROR: _ADMIN_TOOLS not found")
66
+ sys.exit(1)
67
+ content = content.replace(OLD_ADMIN, NEW_ADMIN, 1)
68
+
69
+ # ── 3. Add handler (before final "unknown tool" fallback) ────────────────────
70
+
71
+ HANDLER = ''' if name == "chain_release":
72
+ from .dependency_manager import get_artifact_id, get_release_version, update_dependency
73
+ from .git_manager import commit_file_change, push_dep_update
74
+ from .cicd_trigger import _trigger_jenkins_release
75
+ from .notify import slack_alert
76
+
77
+ chain_repos = inputs.get("chain", [])
78
+ confirmed = bool(inputs.get("confirmed", False))
79
+
80
+ if not chain_repos or len(chain_repos) < 2:
81
+ return json.dumps({"error": "chain must contain at least 2 repos"})
82
+
83
+ # Resolve repo configs (fuzzy match)
84
+ resolved = []
85
+ for rname in chain_repos:
86
+ repo = cfg_loader.repos.get(rname)
87
+ if not repo:
88
+ for k, v in cfg_loader.repos.items():
89
+ if rname.lower() in k.lower():
90
+ repo = v
91
+ rname = k
92
+ break
93
+ if not repo:
94
+ return json.dumps({"error": f"Repo not found: {rname}. Known: {list(cfg_loader.repos.keys())}"})
95
+ resolved.append(repo)
96
+
97
+ # Gather artifact IDs and release versions for each repo
98
+ steps = []
99
+ for repo in resolved:
100
+ artifact_id = get_artifact_id(repo.local_path) if repo.local_path else ""
101
+ release_ver = get_release_version(repo.local_path) if repo.local_path else ""
102
+ steps.append({
103
+ "repo": repo.repo_name,
104
+ "artifact_id": artifact_id,
105
+ "release_version": release_ver,
106
+ "cicd_url": repo.cicd_job_url,
107
+ "auto_publish": repo.auto_publish,
108
+ })
109
+
110
+ # ── Plan phase ───────────────────────────────────────────────────────
111
+ if not confirmed:
112
+ plan_steps = []
113
+ for i, step in enumerate(steps):
114
+ if i == 0:
115
+ desc = f"Release {step['repo']} v{step['release_version']}"
116
+ else:
117
+ prev = steps[i - 1]
118
+ desc = (
119
+ f"Update {step['repo']} dep on {prev['artifact_id']} "
120
+ f"→ {prev['release_version']}, then release v{step['release_version']}"
121
+ )
122
+ plan_steps.append({
123
+ "step": i + 1,
124
+ "action": desc,
125
+ "repo": step["repo"],
126
+ "release_version": step["release_version"],
127
+ "jenkins_url": step["cicd_url"],
128
+ "mode": "push to main" if step["auto_publish"] else "open PR",
129
+ })
130
+ return json.dumps({
131
+ "plan": f"Sequential release chain: {' → '.join(s['repo'] for s in steps)}",
132
+ "steps": plan_steps,
133
+ "confirm_prompt": "Reply with confirmed=true to execute all steps in sequence.",
134
+ })
135
+
136
+ # ── Execute phase ─────────────────────────────────────────────────────
137
+ cfg = cfg_loader.sentinel
138
+ slack_alert(
139
+ cfg.slack_bot_token, cfg.slack_channel,
140
+ f":chains: *Chain release started* ({len(steps)} steps)\\n"
141
+ + " \\u2192 ".join(s["repo"] for s in steps),
142
+ )
143
+
144
+ results = []
145
+ for i, (step, repo) in enumerate(zip(steps, resolved)):
146
+ step_label = f"Step {i + 1}/{len(steps)}"
147
+
148
+ # Update dependency on previous repo's artifact
149
+ if i > 0:
150
+ prev = steps[i - 1]
151
+ if not prev["artifact_id"] or not prev["release_version"]:
152
+ msg = f"{step_label}: skipped — could not read artifact/version from {prev['repo']}"
153
+ results.append({"step": i + 1, "repo": step["repo"], "status": "skipped", "error": msg})
154
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f":warning: {msg}")
155
+ break
156
+ changed = update_dependency(repo.local_path, prev["artifact_id"], prev["release_version"])
157
+ if changed:
158
+ commit_msg = (
159
+ f"chore(deps): update {prev['artifact_id']} to {prev['release_version']} [sentinel-chain]\\n\\n"
160
+ f"Part of release chain: {' -> '.join(s['repo'] for s in steps)}"
161
+ )
162
+ status, commit_hash = commit_file_change(repo, ["pom.xml"], commit_msg)
163
+ if status != "committed":
164
+ msg = f"{step_label}: git commit failed for {repo.repo_name}"
165
+ results.append({"step": i + 1, "repo": step["repo"], "status": "failed", "error": msg})
166
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
167
+ f":x: *Chain release failed at {step_label}*\\n{msg}")
168
+ break
169
+ branch, pr_url = push_dep_update(repo, cfg, prev["artifact_id"], prev["release_version"], commit_hash)
170
+ action = f"<{pr_url}|PR opened>" if pr_url else f"pushed to `{branch}`"
171
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
172
+ f":arrow_right: {step_label}: Updated `{repo.repo_name}` "
173
+ f"`{prev['artifact_id']}` \\u2192 `{prev['release_version']}` ({action})")
174
+ else:
175
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
176
+ f":information_source: {step_label}: `{repo.repo_name}` dep already at "
177
+ f"`{prev['release_version']}` — skipping pom.xml update")
178
+
179
+ # Trigger Jenkins release
180
+ if repo.cicd_job_url and repo.cicd_type:
181
+ success = _trigger_jenkins_release(repo)
182
+ if success:
183
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
184
+ f":rocket: {step_label}: Jenkins release triggered — "
185
+ f"`{repo.repo_name}` v{step['release_version']}")
186
+ results.append({"step": i + 1, "repo": step["repo"], "status": "released",
187
+ "version": step["release_version"]})
188
+ else:
189
+ msg = f"Jenkins release trigger failed for {repo.repo_name}"
190
+ results.append({"step": i + 1, "repo": step["repo"], "status": "failed", "error": msg})
191
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
192
+ f":x: *Chain release failed at {step_label}*\\n{msg}")
193
+ break
194
+ else:
195
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
196
+ f":information_source: {step_label}: `{repo.repo_name}` has no CI/CD — "
197
+ f"pom.xml updated but Jenkins not triggered")
198
+ results.append({"step": i + 1, "repo": step["repo"], "status": "no_cicd",
199
+ "note": "pom.xml updated, no Jenkins trigger configured"})
200
+
201
+ all_ok = all(r.get("status") in ("released", "no_cicd") for r in results)
202
+ final = "completed" if all_ok else "partial" if results else "failed"
203
+ slack_alert(
204
+ cfg.slack_bot_token, cfg.slack_channel,
205
+ f":{'white_check_mark' if all_ok else 'warning'}: *Chain release {final}* \\u2014 "
206
+ + ", ".join(
207
+ f"`{r['repo']}` {'\\u2713' if r.get('status') in ('released', 'no_cicd') else '\\u2717'}"
208
+ for r in results
209
+ ),
210
+ )
211
+ logger.info("Boss chain_release: %s by %s — %s", final, user_id, results)
212
+ return json.dumps({"status": final, "steps": results})
213
+
214
+ '''
215
+
216
+ OLD_FALLBACK = ' return json.dumps({"error": f"unknown tool: {name}"})'
217
+ if OLD_FALLBACK not in content:
218
+ print("ERROR: unknown tool fallback not found")
219
+ sys.exit(1)
220
+ content = content.replace(OLD_FALLBACK, HANDLER + OLD_FALLBACK, 1)
221
+
222
+ with open(TARGET, 'w', encoding='utf-8') as f:
223
+ f.write(content)
224
+
225
+ print("Patch applied successfully.")
226
+
227
+ # Verify
228
+ import ast, py_compile, tempfile, os
229
+ with tempfile.NamedTemporaryFile(suffix='.py', delete=False, mode='w') as tmp:
230
+ tmp.write(content)
231
+ tmp_name = tmp.name
232
+ try:
233
+ py_compile.compile(tmp_name, doraise=True)
234
+ print("Syntax OK")
235
+ finally:
236
+ os.unlink(tmp_name)
@@ -7,6 +7,7 @@ triggered by the normal GitHub merge webhook.
7
7
 
8
8
  import logging
9
9
  import re
10
+ import time
10
11
  from pathlib import Path
11
12
 
12
13
  import requests
@@ -16,8 +17,12 @@ from .state_store import StateStore
16
17
 
17
18
  logger = logging.getLogger(__name__)
18
19
 
20
+ # How long to wait for a Jenkins release build to complete (chain releases can be slow)
21
+ JENKINS_RELEASE_TIMEOUT = 900 # 15 minutes
22
+ JENKINS_POLL_INTERVAL = 20 # seconds between polls
19
23
 
20
- def trigger(repo: RepoConfig, store: StateStore, fingerprint: str) -> bool:
24
+
25
+ def trigger(repo: RepoConfig, store: StateStore, fingerprint: str, wait: bool = False) -> bool:
21
26
  if not repo.cicd_type or not repo.cicd_job_url:
22
27
  return True
23
28
 
@@ -25,7 +30,7 @@ def trigger(repo: RepoConfig, store: StateStore, fingerprint: str) -> bool:
25
30
  if cicd_type == "jenkins":
26
31
  return _trigger_jenkins(repo)
27
32
  elif cicd_type in ("jenkins_release", "jenkins-release"):
28
- return _trigger_jenkins_release(repo)
33
+ return _trigger_jenkins_release(repo, wait=wait)
29
34
  elif cicd_type in ("github_actions", "github-actions"):
30
35
  return _trigger_github_actions(repo, fingerprint)
31
36
  else:
@@ -44,29 +49,133 @@ def _trigger_jenkins(repo: RepoConfig) -> bool:
44
49
  return success
45
50
 
46
51
 
47
- def _trigger_jenkins_release(repo: RepoConfig) -> bool:
48
- # Maven Release Plugin POST to /m2release/submit with release + dev versions
52
+ def _get_last_build_number(session: requests.Session, job_url: str) -> int:
53
+ """Return the current lastBuild number for a Jenkins job, or 0 if none."""
54
+ try:
55
+ r = session.get(f"{job_url.rstrip('/')}/api/json", timeout=10)
56
+ data = r.json()
57
+ lb = data.get("lastBuild")
58
+ return lb["number"] if lb else 0
59
+ except Exception as exc:
60
+ logger.warning("Could not get last build number for %s: %s", job_url, exc)
61
+ return 0
62
+
63
+
64
+ def _wait_for_jenkins_build(
65
+ session: requests.Session,
66
+ job_url: str,
67
+ prev_build_num: int,
68
+ timeout: int = JENKINS_RELEASE_TIMEOUT,
69
+ ) -> bool:
70
+ """
71
+ Poll until a new build appears and completes. Returns True on SUCCESS.
72
+ Logs progress every poll cycle.
73
+ """
74
+ deadline = time.time() + timeout
75
+ build_num = None
76
+
77
+ # Phase 1: wait for a new build to appear
78
+ while time.time() < deadline:
79
+ current = _get_last_build_number(session, job_url)
80
+ if current > prev_build_num:
81
+ build_num = current
82
+ logger.info("Jenkins build #%d started for %s", build_num, job_url)
83
+ break
84
+ logger.debug("Waiting for Jenkins build to start (last=%d)...", current)
85
+ time.sleep(JENKINS_POLL_INTERVAL)
86
+ else:
87
+ logger.error("Timed out waiting for Jenkins build to start: %s", job_url)
88
+ return False
89
+
90
+ # Phase 2: poll until build completes
91
+ build_api = f"{job_url.rstrip('/')}/{build_num}/api/json"
92
+ while time.time() < deadline:
93
+ try:
94
+ r = session.get(build_api, timeout=10)
95
+ data = r.json()
96
+ if not data.get("building", True):
97
+ result = data.get("result", "UNKNOWN")
98
+ if result == "SUCCESS":
99
+ logger.info("Jenkins build #%d SUCCESS: %s", build_num, job_url)
100
+ return True
101
+ else:
102
+ logger.error("Jenkins build #%d %s: %s", build_num, result, job_url)
103
+ return False
104
+ elapsed = int(time.time() - (deadline - timeout))
105
+ logger.info("Jenkins build #%d still running (%ds elapsed)...", build_num, elapsed)
106
+ except Exception as exc:
107
+ logger.warning("Jenkins poll error for %s: %s", build_api, exc)
108
+ time.sleep(JENKINS_POLL_INTERVAL)
109
+
110
+ logger.error("Timed out waiting for Jenkins build #%d to complete: %s", build_num, job_url)
111
+ return False
112
+
113
+
114
+ def _trigger_jenkins_release(repo: RepoConfig, wait: bool = False) -> bool:
115
+ # Maven Release Plugin — POST to /m2release/submit
116
+ # Jenkins/Stapler requires a `json` parameter containing the form data or it returns 400.
49
117
  release_ver, dev_ver = _maven_release_versions(repo.local_path)
118
+ if not release_ver:
119
+ logger.error("Jenkins release: could not determine release version from pom.xml")
120
+ return False
50
121
  url = f"{repo.cicd_job_url.rstrip('/')}/m2release/submit"
51
- resp = requests.post(
122
+ auth = (repo.cicd_user or "sentinel", repo.cicd_token)
123
+
124
+ # Fetch crumb (CSRF token) using a session so the cookie is preserved
125
+ session = requests.Session()
126
+ session.auth = auth
127
+ from urllib.parse import urlparse
128
+ parsed = urlparse(repo.cicd_job_url)
129
+ jenkins_root = f"{parsed.scheme}://{parsed.netloc}"
130
+ crumb_data = session.get(f"{jenkins_root}/crumbIssuer/api/json", timeout=10).json()
131
+ crumb_header = {crumb_data["crumbRequestField"]: crumb_data["crumb"]}
132
+
133
+ # Record current last build number before triggering
134
+ prev_build_num = _get_last_build_number(session, repo.cicd_job_url) if wait else 0
135
+
136
+ scm_tag = f"{repo.repo_name}-{release_ver}"
137
+ import json as _json
138
+ stapler_json = _json.dumps({
139
+ "releaseVersion": release_ver,
140
+ "developmentVersion": dev_ver,
141
+ "isDryRun": False,
142
+ "specifyScmCredentials": False,
143
+ "scmUsername": "",
144
+ "scmCommentPrefix": "[maven-release-plugin] ",
145
+ "appendHudsonUserName": False,
146
+ "specifyScmTag": False,
147
+ "scmTag": scm_tag,
148
+ })
149
+ resp = session.post(
52
150
  url,
53
- auth=(repo.cicd_user or "sentinel", repo.cicd_token),
151
+ headers={
152
+ **crumb_header,
153
+ "Referer": f"{repo.cicd_job_url.rstrip('/')}/m2release/",
154
+ },
54
155
  data={
55
- "releaseVersion": release_ver,
156
+ "releaseVersion": release_ver,
56
157
  "developmentVersion": dev_ver,
57
- "isDryRun": "false",
58
- "specifyScmCredentials": "false",
59
- "specifyCustomScmCommentPrefix": "false",
60
- "specifyCustomScmTag": "false",
158
+ "scmCommentPrefix": "[maven-release-plugin] ",
159
+ "scmTag": scm_tag,
160
+ "json": stapler_json,
61
161
  },
62
162
  timeout=15,
163
+ allow_redirects=False,
63
164
  )
64
- success = resp.status_code in (200, 201, 204)
65
- if success:
66
- logger.info("Jenkins Maven Release triggered: %s → %s (next: %s)", repo.cicd_job_url, release_ver, dev_ver)
67
- else:
165
+ triggered = resp.status_code in (200, 201, 204, 302)
166
+ if not triggered:
68
167
  logger.error("Jenkins release trigger failed (%s): %s", resp.status_code, resp.text[:200])
69
- return success
168
+ return False
169
+
170
+ logger.info(
171
+ "Jenkins Maven Release triggered: %s → %s (next: %s)%s",
172
+ repo.cicd_job_url, release_ver, dev_ver,
173
+ " — waiting for completion..." if wait else "",
174
+ )
175
+
176
+ if wait:
177
+ return _wait_for_jenkins_build(session, repo.cicd_job_url, prev_build_num)
178
+ return True
70
179
 
71
180
 
72
181
  def _maven_release_versions(local_path: str) -> tuple[str, str]:
@@ -33,12 +33,14 @@ class CascadeResult:
33
33
  # ── pom.xml helpers ────────────────────────────────────────────────────────────
34
34
 
35
35
  def get_artifact_id(local_path: str) -> str:
36
- """Return the root <artifactId> from pom.xml."""
36
+ """Return the root <artifactId> from pom.xml (skips <parent> block)."""
37
37
  pom = Path(local_path) / "pom.xml"
38
38
  if not pom.exists():
39
39
  return ""
40
40
  text = pom.read_text(encoding="utf-8", errors="ignore")
41
- m = re.search(r"<artifactId>([^<]+)</artifactId>", text)
41
+ # Strip <parent>...</parent> so we don't pick up the parent artifactId
42
+ text_no_parent = re.sub(r"<parent>.*?</parent>", "", text, flags=re.DOTALL)
43
+ m = re.search(r"<artifactId>([^<]+)</artifactId>", text_no_parent)
42
44
  return m.group(1).strip() if m else ""
43
45
 
44
46
 
@@ -57,6 +59,17 @@ def get_release_version(local_path: str) -> str:
57
59
  return get_pom_version(local_path).replace("-SNAPSHOT", "")
58
60
 
59
61
 
62
+ def _resolve_property(prop_ref: str, text: str) -> str:
63
+ """Resolve a Maven ${property.name} reference from the <properties> section."""
64
+ if not prop_ref.startswith("${"):
65
+ return prop_ref
66
+ prop_name = prop_ref[2:-1] # strip ${ and }
67
+ m = re.search(
68
+ rf"<{re.escape(prop_name)}>([^<]+)</{re.escape(prop_name)}>", text
69
+ )
70
+ return m.group(1).strip() if m else prop_ref
71
+
72
+
60
73
  def find_dependents(
61
74
  artifact_id: str,
62
75
  repos: dict,
@@ -75,18 +88,28 @@ def find_dependents(
75
88
  if not pom.exists():
76
89
  continue
77
90
  text = pom.read_text(encoding="utf-8", errors="ignore")
78
- pattern = rf"<dependency>.*?<artifactId>{re.escape(artifact_id)}</artifactId>.*?</dependency>"
79
- block_m = re.search(pattern, text, re.DOTALL)
80
- if block_m:
81
- ver_m = re.search(r"<version>([^<]+)</version>", block_m.group(0))
82
- current_ver = ver_m.group(1).strip() if ver_m else "?"
91
+ # Iterate individual blocks to avoid cross-boundary matching
92
+ for block_m in re.finditer(r"<dependency>(.*?)</dependency>", text, re.DOTALL):
93
+ inner = block_m.group(1)
94
+ if not re.search(
95
+ rf"<artifactId>\s*{re.escape(artifact_id)}\s*</artifactId>", inner
96
+ ):
97
+ continue
98
+ ver_m = re.search(r"<version>([^<]+)</version>", inner)
99
+ if ver_m:
100
+ raw = ver_m.group(1).strip()
101
+ current_ver = _resolve_property(raw, text)
102
+ else:
103
+ current_ver = "?"
83
104
  results.append((repo, current_ver))
105
+ break
84
106
  return sorted(results, key=lambda t: t[0].repo_name)
85
107
 
86
108
 
87
109
  def update_dependency(local_path: str, artifact_id: str, new_version: str) -> bool:
88
110
  """
89
111
  Update the <version> inside the <dependency> block for artifact_id in pom.xml.
112
+ Handles both direct versions and ${property} references (updates <properties> section).
90
113
  Returns True if the file was changed.
91
114
  """
92
115
  pom = Path(local_path) / "pom.xml"
@@ -94,13 +117,58 @@ def update_dependency(local_path: str, artifact_id: str, new_version: str) -> bo
94
117
  return False
95
118
  text = pom.read_text(encoding="utf-8", errors="ignore")
96
119
 
97
- def _replace_version(match: re.Match) -> str:
98
- block = match.group(0)
99
- return re.sub(r"<version>[^<]+</version>", f"<version>{new_version}</version>", block, count=1)
120
+ # First pass: find the target block and check for property reference
121
+ prop_name: str | None = None
122
+ found = False
123
+ for block_m in re.finditer(r"<dependency>(.*?)</dependency>", text, re.DOTALL):
124
+ inner = block_m.group(1)
125
+ if not re.search(
126
+ rf"<artifactId>\s*{re.escape(artifact_id)}\s*</artifactId>", inner
127
+ ):
128
+ continue
129
+ found = True
130
+ ver_m = re.search(r"<version>\$\{([^}]+)\}</version>", inner)
131
+ if ver_m:
132
+ prop_name = ver_m.group(1)
133
+ break
134
+
135
+ if not found:
136
+ return False
137
+
138
+ if prop_name:
139
+ # Update the property in <properties> section instead of the inline version
140
+ new_text, n = re.subn(
141
+ rf"(<{re.escape(prop_name)}>)[^<]+(</\s*{re.escape(prop_name)}>)",
142
+ rf"\g<1>{new_version}\2",
143
+ text,
144
+ )
145
+ if n == 0 or new_text == text:
146
+ logger.warning(
147
+ "update_dependency: property '%s' not found in <properties> for %s",
148
+ prop_name, artifact_id,
149
+ )
150
+ return False
151
+ else:
152
+ # Direct version — replace only inside the matching block, block by block
153
+ def _replace_block(m: re.Match) -> str:
154
+ inner = m.group(1)
155
+ if not re.search(
156
+ rf"<artifactId>\s*{re.escape(artifact_id)}\s*</artifactId>", inner
157
+ ):
158
+ return m.group(0)
159
+ new_inner = re.sub(
160
+ r"<version>[^<]+</version>",
161
+ f"<version>{new_version}</version>",
162
+ inner,
163
+ count=1,
164
+ )
165
+ return f"<dependency>{new_inner}</dependency>"
166
+
167
+ new_text = re.sub(
168
+ r"<dependency>(.*?)</dependency>", _replace_block, text, flags=re.DOTALL
169
+ )
100
170
 
101
- pattern = rf"<dependency>.*?<artifactId>{re.escape(artifact_id)}</artifactId>.*?</dependency>"
102
- new_text, n = re.subn(pattern, _replace_version, text, flags=re.DOTALL)
103
- if n == 0 or new_text == text:
171
+ if new_text == text:
104
172
  return False
105
173
  pom.write_text(new_text, encoding="utf-8")
106
174
  return True
@@ -164,7 +232,8 @@ def execute_cascade(
164
232
  Returns one CascadeResult per dependent repo attempted.
165
233
  """
166
234
  # Import here to avoid circular imports
167
- from .git_manager import commit_file_change, push_dep_update, open_dep_pr, _git_env
235
+ from .git_manager import commit_file_change, push_dep_update, open_dep_pr, _git_env, _git, maven_compile_check, MissingToolError # noqa: E501
236
+ from .notify import notify_cascade_build_failed
168
237
 
169
238
  all_dependents = find_dependents(artifact_id, repos, skip_repo=source_repo_name)
170
239
  if target_repo_names:
@@ -175,27 +244,69 @@ def execute_cascade(
175
244
  for repo, old_version in all_dependents:
176
245
  result = CascadeResult(repo_name=repo.repo_name, success=False, old_version=old_version, new_version=new_version)
177
246
  try:
247
+ # Ensure clean working tree before pulling — a previous failed attempt
248
+ # may have left pom.xml dirty, which causes git pull --rebase to fail.
249
+ _git(["checkout", "pom.xml"], cwd=repo.local_path, env=_git_env(repo))
250
+
251
+ # Pull BEFORE modifying pom.xml — git pull --rebase fails on dirty working tree
252
+ pull_r = _git(["pull", "--rebase", "origin", repo.branch], cwd=repo.local_path, env=_git_env(repo))
253
+ if pull_r.returncode != 0:
254
+ result.error = f"git pull failed: {pull_r.stderr.strip()[:200]}"
255
+ results.append(result)
256
+ continue
257
+
178
258
  changed = update_dependency(repo.local_path, artifact_id, new_version)
179
259
  if not changed:
180
260
  result.error = "pom.xml not changed (already up to date?)"
181
261
  results.append(result)
182
262
  continue
183
263
 
264
+ # Dry-run: compile before committing — catch broken deps early
265
+ try:
266
+ compile_ok, compile_output = maven_compile_check(repo.local_path)
267
+ except MissingToolError:
268
+ compile_ok, compile_output = False, "mvn not installed on this server"
269
+
270
+ if not compile_ok:
271
+ # Revert pom.xml — never commit a broken dep update
272
+ _git(["checkout", "pom.xml"], cwd=repo.local_path, env=_git_env(repo))
273
+ result.error = f"Maven compile failed — pom.xml reverted: {compile_output[-200:]}"
274
+ logger.error(
275
+ "Cascade build check failed for %s after bumping %s=%s:\n%s",
276
+ repo.repo_name, artifact_id, new_version, compile_output[-500:],
277
+ )
278
+ notify_cascade_build_failed(
279
+ cfg, repo.repo_name, artifact_id, new_version, compile_output,
280
+ )
281
+ results.append(result)
282
+ continue
283
+
184
284
  commit_msg = (
185
285
  f"chore(deps): update {artifact_id} to {new_version} [sentinel]\n\n"
186
286
  f"Automated dependency bump by Sentinel after {source_repo_name} release."
187
287
  )
188
- status, commit_hash = commit_file_change(repo, ["pom.xml"], commit_msg)
288
+ status, commit_hash = commit_file_change(repo, ["pom.xml"], commit_msg, skip_pull=True)
189
289
  if status != "committed":
190
290
  result.error = "git commit failed"
191
291
  results.append(result)
192
292
  continue
193
293
 
194
- branch, pr_url = push_dep_update(repo, cfg, artifact_id, new_version, commit_hash)
195
- result.success = True
294
+ branch, pr_url, push_ok = push_dep_update(repo, cfg, artifact_id, new_version, commit_hash)
196
295
  result.branch = branch
197
296
  result.pr_url = pr_url
198
- logger.info("Cascade updated %s → %s=%s (branch: %s)", repo.repo_name, artifact_id, new_version, branch)
297
+ if push_ok:
298
+ result.success = True
299
+ logger.info(
300
+ "Cascade updated %s → %s=%s (branch: %s)",
301
+ repo.repo_name, artifact_id, new_version, branch,
302
+ )
303
+ else:
304
+ result.success = False
305
+ result.error = "commit is local only — no push access; Jenkins will handle its own release push"
306
+ logger.warning(
307
+ "Cascade committed %s → %s=%s locally but push skipped (no write access)",
308
+ repo.repo_name, artifact_id, new_version,
309
+ )
199
310
 
200
311
  except Exception as exc:
201
312
  result.error = str(exc)