@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.
- package/lib/.cairn/minify-map.json +12 -0
- package/lib/.cairn/views/244a09_generate.js +274 -0
- package/lib/.cairn/views/fb78ac_upgrade.js +16 -1
- package/lib/generate.js +6 -1
- package/package.json +1 -1
- package/python/scripts/fix_ask_codebase_context.py +249 -0
- package/python/scripts/fix_ask_codebase_stdin.py +49 -0
- package/python/scripts/fix_chain_slack.py +67 -0
- package/python/scripts/fix_fstring.py +51 -0
- package/python/scripts/fix_knowledge_cache.py +323 -0
- package/python/scripts/fix_knowledge_cache_staleness.py +294 -0
- package/python/scripts/fix_merge_confirm.py +295 -0
- package/python/scripts/fix_permission_messages.py +78 -0
- package/python/scripts/fix_pr_check_head_detect.py +84 -0
- package/python/scripts/fix_pr_msg_newlines.py +57 -0
- package/python/scripts/fix_pr_tracking_boss.py +265 -0
- package/python/scripts/fix_pr_tracking_db.py +212 -0
- package/python/scripts/fix_pr_tracking_main.py +174 -0
- package/python/scripts/fix_project_isolation.py +197 -0
- package/python/scripts/fix_system_prompt.py +444 -0
- package/python/scripts/fix_two_bugs.py +220 -0
- package/python/scripts/patch_chain_release.py +236 -0
- package/python/sentinel/cicd_trigger.py +125 -16
- package/python/sentinel/dependency_manager.py +129 -18
- package/python/sentinel/git_manager.py +46 -12
- package/python/sentinel/notify.py +34 -0
- package/python/sentinel/sentinel_boss.py +4139 -3326
|
@@ -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
|
-
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
headers={
|
|
152
|
+
**crumb_header,
|
|
153
|
+
"Referer": f"{repo.cicd_job_url.rstrip('/')}/m2release/",
|
|
154
|
+
},
|
|
54
155
|
data={
|
|
55
|
-
"releaseVersion":
|
|
156
|
+
"releaseVersion": release_ver,
|
|
56
157
|
"developmentVersion": dev_ver,
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
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
|
-
|
|
65
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
block_m
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|