@misterhuydo/sentinel 1.4.68 → 1.4.70

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.
@@ -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)
@@ -51,6 +51,27 @@ def _git_env(repo: RepoConfig) -> dict:
51
51
  return env
52
52
 
53
53
 
54
+ def maven_compile_check(local_path: str, timeout: int = 300) -> tuple[bool, str]:
55
+ """
56
+ Run `mvn compile -DskipTests -q --batch-mode` to verify the pom.xml is valid
57
+ and all dependencies resolve before committing.
58
+ Returns (success, output). Raises MissingToolError if mvn is not installed.
59
+ """
60
+ import shutil
61
+ mvn = shutil.which("mvn")
62
+ if not mvn:
63
+ raise MissingToolError("mvn")
64
+ r = subprocess.run(
65
+ [mvn, "compile", "-DskipTests", "-q", "--batch-mode"],
66
+ cwd=local_path,
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=timeout,
70
+ )
71
+ output = (r.stdout + r.stderr).strip()
72
+ return r.returncode == 0, output
73
+
74
+
54
75
  def _check_protected_paths(patch_path: Path) -> bool:
55
76
  text = patch_path.read_text(encoding="utf-8", errors="replace")
56
77
  for line in text.splitlines():
@@ -162,19 +183,24 @@ def commit_file_change(
162
183
  repo: RepoConfig,
163
184
  files: list[str],
164
185
  commit_msg: str,
186
+ skip_pull: bool = False,
165
187
  ) -> tuple[str, str]:
166
188
  """
167
- Pull, stage the given files, and commit.
189
+ Stage the given files and commit, optionally pulling first.
168
190
  Returns (status, commit_hash) — status: "committed" | "failed".
169
191
  Does NOT run tests (dependency bumps don't need them).
192
+
193
+ skip_pull=True when the caller already pulled before modifying files
194
+ (git pull --rebase fails on a dirty working tree).
170
195
  """
171
196
  env = _git_env(repo)
172
197
  local_path = repo.local_path
173
198
 
174
- r = _git(["pull", "--rebase", "origin", repo.branch], cwd=local_path, env=env)
175
- if r.returncode != 0:
176
- logger.error("git pull failed for %s:\n%s", repo.repo_name, r.stderr)
177
- return "failed", ""
199
+ if not skip_pull:
200
+ r = _git(["pull", "--rebase", "origin", repo.branch], cwd=local_path, env=env)
201
+ if r.returncode != 0:
202
+ logger.error("git pull failed for %s:\n%s", repo.repo_name, r.stderr)
203
+ return "failed", ""
178
204
 
179
205
  _git(["add"] + files, cwd=local_path, env=env)
180
206
  r = _git(["commit", "-m", commit_msg], cwd=local_path, env=env)
@@ -194,11 +220,12 @@ def push_dep_update(
194
220
  artifact_id: str,
195
221
  new_version: str,
196
222
  commit_hash: str,
197
- ) -> tuple[str, str]:
223
+ ) -> tuple[str, str, bool]:
198
224
  """
199
225
  Push a dependency update commit. AUTO_PUBLISH=true → main branch.
200
226
  AUTO_PUBLISH=false → sentinel/dep-<artifact>-<version> branch + open PR.
201
- Returns (branch, pr_url).
227
+ Returns (branch, pr_url, push_ok).
228
+ push_ok=False means the commit is local only (no write access) — chain release continues.
202
229
  """
203
230
  env = _git_env(repo)
204
231
  local_path = repo.local_path
@@ -206,8 +233,12 @@ def push_dep_update(
206
233
  if repo.auto_publish:
207
234
  r = _git(["push", "origin", repo.branch], cwd=local_path, env=env)
208
235
  if r.returncode != 0:
209
- logger.error("git push failed for %s:\n%s", repo.repo_name, r.stderr)
210
- return repo.branch, ""
236
+ logger.warning(
237
+ "git push failed for %s (commit is local only): %s",
238
+ repo.repo_name, r.stderr.strip().splitlines()[0] if r.stderr else "",
239
+ )
240
+ return repo.branch, "", False
241
+ return repo.branch, "", True
211
242
  else:
212
243
  safe_artifact = re.sub(r"[^a-zA-Z0-9_-]", "-", artifact_id)
213
244
  safe_version = re.sub(r"[^a-zA-Z0-9._-]", "-", new_version)
@@ -215,12 +246,15 @@ def push_dep_update(
215
246
  _git(["checkout", "-B", branch], cwd=local_path, env=env)
216
247
  r = _git(["push", "-u", "origin", branch], cwd=local_path, env=env)
217
248
  if r.returncode != 0:
218
- logger.error("git push branch failed for %s:\n%s", repo.repo_name, r.stderr)
249
+ logger.warning(
250
+ "git push branch failed for %s (commit is local only): %s",
251
+ repo.repo_name, r.stderr.strip().splitlines()[0] if r.stderr else "",
252
+ )
219
253
  _git(["checkout", repo.branch], cwd=local_path, env=env)
220
- return branch, ""
254
+ return branch, "", False
221
255
  _git(["checkout", repo.branch], cwd=local_path, env=env)
222
256
  pr_url = open_dep_pr(repo, cfg, branch, artifact_id, new_version, commit_hash)
223
- return branch, pr_url
257
+ return branch, pr_url, True
224
258
 
225
259
 
226
260
  def open_dep_pr(
@@ -355,6 +355,40 @@ def notify_cascade_result(
355
355
  slack_alert(cfg.slack_bot_token, cfg.slack_channel, text)
356
356
 
357
357
 
358
+ def notify_cascade_build_failed(
359
+ cfg,
360
+ repo_name: str,
361
+ artifact_id: str,
362
+ new_version: str,
363
+ build_output: str,
364
+ user_id: str = "",
365
+ ) -> None:
366
+ """Alert admin when a Maven compile check fails after a dep bump — pom.xml was reverted."""
367
+ # Maven errors accumulate at the bottom — show the last 600 chars
368
+ snippet = build_output.strip()[-600:]
369
+ text = (
370
+ f":x: *Cascade build check failed — `{repo_name}`*\n"
371
+ f"Bumping `{artifact_id}` \u2192 `{new_version}` caused a compile error.\n"
372
+ f"`pom.xml` has been reverted. Manual intervention required.\n"
373
+ f"```{snippet}```"
374
+ )
375
+ if user_id:
376
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{user_id}> {text}")
377
+ else:
378
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<!channel> {text}")
379
+ try:
380
+ from .reporter import send_failure_notification
381
+ send_failure_notification(cfg, {
382
+ "source": repo_name,
383
+ "message": f"Cascade dep bump {artifact_id}={new_version} failed Maven compile",
384
+ "repo_name": repo_name,
385
+ "reason": f"Maven compile error after dep bump: {build_output[:300]}",
386
+ "body": build_output,
387
+ })
388
+ except Exception as exc:
389
+ logger.warning("notify_cascade_build_failed: email notification failed: %s", exc)
390
+
391
+
358
392
  def alert_if_rate_limited(
359
393
  bot_token: str,
360
394
  channel: str,