its-magic 0.1.3-0 → 0.1.3-2
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/installer.ps1 +8 -0
- package/installer.py +8 -0
- package/installer.sh +1 -0
- package/package.json +1 -1
- package/scripts/check_intake_template_parity.py +62 -0
- package/template/.cursor/agents/curator.mdc +1 -0
- package/template/.cursor/agents/po.mdc +1 -0
- package/template/.cursor/agents/release.mdc +1 -0
- package/template/.cursor/commands/auto.md +90 -0
- package/template/.cursor/commands/execute.md +26 -0
- package/template/.cursor/commands/refresh-context.md +32 -0
- package/template/.cursor/commands/sovereign-critic.md +104 -0
- package/template/.cursor/model-catalog.local.example.cursor-only.json +9 -0
- package/template/.cursor/model-catalog.local.example.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-1-easy.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-2-complex.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-3-mega.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-4-super.json +9 -0
- package/template/.cursor/model-catalog.local.example.role-based-balanced.json +18 -0
- package/template/.cursor/model-catalog.local.example.role-based-highend.json +18 -0
- package/template/.cursor/rules/sovereign-role-manifest.mdc.example +27 -0
- package/template/.cursor/scratchpad.local.example.md +55 -0
- package/template/.cursor/scratchpad.md +203 -0
- package/template/.cursor/sovereign-role-manifest.yaml.example +53 -0
- package/template/decisions/DEC-0104.md +279 -0
- package/template/decisions/DEC-0105.md +231 -0
- package/template/decisions/DEC-0107.md +246 -0
- package/template/docs/engineering/architecture.md +78 -0
- package/template/docs/engineering/auto-orchestration-reference.md +7 -0
- package/template/docs/engineering/context/installer-owned-paths.manifest +8 -0
- package/template/docs/engineering/reason_codes.md +411 -0
- package/template/docs/engineering/runbook.md +1119 -0
- package/template/docs/engineering/sovereign-memory/.gitkeep +0 -0
- package/template/docs/engineering/sovereign-memory/retrospectives/.gitkeep +0 -0
- package/template/handoffs/sovereign_decisions/.gitkeep +0 -0
- package/template/handoffs/sovereign_deferrals/.gitkeep +0 -0
- package/template/handoffs/sovereign_role_reviews.jsonl +0 -0
- package/template/scripts/__pycache__/decision_ledger_lib.cpython-312.pyc +0 -0
- package/template/scripts/check_intake_template_parity.py +181 -0
- package/template/scripts/decision_ledger_lib.py +732 -0
- package/template/scripts/ledger_validate.py +153 -0
- package/template/scripts/model_tier_lib.py +680 -0
- package/template/scripts/model_tier_validate.py +368 -0
- package/template/scripts/parallel_dev_arbiter.py +923 -0
- package/template/scripts/release_trigger_adapters.py +843 -0
- package/template/scripts/self_healing_deploy_lib.py +463 -0
- package/template/scripts/self_healing_deploy_validate.py +78 -0
- package/template/scripts/sovereign_convergence_lib.py +994 -0
- package/template/scripts/sovereign_convergence_validate.py +206 -0
- package/template/scripts/sovereign_critic_lib.py +629 -0
- package/template/scripts/sovereign_critic_validate.py +131 -0
- package/template/scripts/sovereign_loop_lib.py +828 -0
- package/template/scripts/sovereign_loop_validate.py +122 -0
- package/template/scripts/sovereign_memory_lib.py +869 -0
- package/template/scripts/sovereign_memory_validate.py +153 -0
- package/template/scripts/sovereign_role_manifest_lib.py +547 -0
- package/template/scripts/sovereign_role_manifest_validate.py +105 -0
- package/template/tests/us0108_contract_test.py +207 -0
- package/template/tests/us0109_contract_test.py +209 -0
- package/template/tests/us0109_us0110_compose_test.py +34 -0
- package/template/tests/us0111_contract_test.py +345 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Release trigger adapters — dispatch release flow by trigger source (US-0111 / DEC-0111).
|
|
3
|
+
|
|
4
|
+
Four adapters:
|
|
5
|
+
- github: GitHub webhook release event → resolve tag + find previous via API
|
|
6
|
+
- npm: npm publish event → read package version + query registry
|
|
7
|
+
- git_tag: Git tag push → parse GITHUB_REF or local git describe
|
|
8
|
+
- manual: Legacy /release command → byte-identical to pre-US-0111
|
|
9
|
+
|
|
10
|
+
All adapters produce TriggerContext(version, previous_version, source, metadata).
|
|
11
|
+
Downstream reuse release_changelog_lib.compare_versions() and promote_unreleased()
|
|
12
|
+
without modification (compose — US-0100 read-only).
|
|
13
|
+
|
|
14
|
+
Reason codes (DEC-0111 §7):
|
|
15
|
+
RELEASE_TRIGGER_ADAPTER_FAILED
|
|
16
|
+
RELEASE_TRIGGER_TAG_MISSING
|
|
17
|
+
RELEASE_TRIGGER_PREVIOUS_MISSING
|
|
18
|
+
RELEASE_TRIGGER_PACKAGE_JSON_MISSING
|
|
19
|
+
RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED
|
|
20
|
+
RELEASE_TRIGGER_NOTES_WRITE_FAILED
|
|
21
|
+
RELEASE_TRIGGER_EVENT_EMIT_FAILED
|
|
22
|
+
RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED
|
|
23
|
+
RELEASE_TRIGGER_SOURCE_INVALID
|
|
24
|
+
|
|
25
|
+
Default source: RELEASE_TRIGGER_SOURCE=manual (zero behavior change vs pre-US-0111).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import abc
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import tempfile
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
42
|
+
|
|
43
|
+
# Semantic version pattern
|
|
44
|
+
SEMVER_RE = re.compile(
|
|
45
|
+
r"^v?([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# --- Scratchpad key contracts (DEC-0111 §1) ----------------------------------
|
|
49
|
+
|
|
50
|
+
RELEASE_TRIGGER_SOURCE_KEY = "RELEASE_TRIGGER_SOURCE"
|
|
51
|
+
RELEASE_TRIGGER_SOURCE_VALUES = frozenset({"manual", "github", "npm", "git_tag", "auto"})
|
|
52
|
+
RELEASE_TRIGGER_SOURCE_DEFAULT = "manual"
|
|
53
|
+
|
|
54
|
+
RELEASE_TRIGGER_TIMEOUT_SEC_KEY = "RELEASE_TRIGGER_TIMEOUT_SEC"
|
|
55
|
+
RELEASE_TRIGGER_TIMEOUT_SEC_DEFAULT = 10
|
|
56
|
+
|
|
57
|
+
RELEASE_TRIGGER_FALLBACK_TO_LOCAL_KEY = "RELEASE_TRIGGER_FALLBACK_TO_LOCAL"
|
|
58
|
+
RELEASE_TRIGGER_FALLBACK_TO_LOCAL_DEFAULT = "0"
|
|
59
|
+
|
|
60
|
+
# --- Reason codes (DEC-0111 §7, 9 codes) -------------------------------------
|
|
61
|
+
|
|
62
|
+
RELEASE_TRIGGER_ADAPTER_FAILED = "RELEASE_TRIGGER_ADAPTER_FAILED"
|
|
63
|
+
RELEASE_TRIGGER_TAG_MISSING = "RELEASE_TRIGGER_TAG_MISSING"
|
|
64
|
+
RELEASE_TRIGGER_PREVIOUS_MISSING = "RELEASE_TRIGGER_PREVIOUS_MISSING"
|
|
65
|
+
RELEASE_TRIGGER_PACKAGE_JSON_MISSING = "RELEASE_TRIGGER_PACKAGE_JSON_MISSING"
|
|
66
|
+
RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED = "RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED"
|
|
67
|
+
RELEASE_TRIGGER_NOTES_WRITE_FAILED = "RELEASE_TRIGGER_NOTES_WRITE_FAILED"
|
|
68
|
+
RELEASE_TRIGGER_EVENT_EMIT_FAILED = "RELEASE_TRIGGER_EVENT_EMIT_FAILED"
|
|
69
|
+
RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED = "RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED"
|
|
70
|
+
RELEASE_TRIGGER_SOURCE_INVALID = "RELEASE_TRIGGER_SOURCE_INVALID"
|
|
71
|
+
|
|
72
|
+
FAIL_CODES: Tuple[str, ...] = (
|
|
73
|
+
RELEASE_TRIGGER_ADAPTER_FAILED,
|
|
74
|
+
RELEASE_TRIGGER_TAG_MISSING,
|
|
75
|
+
RELEASE_TRIGGER_PREVIOUS_MISSING,
|
|
76
|
+
RELEASE_TRIGGER_PACKAGE_JSON_MISSING,
|
|
77
|
+
RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED,
|
|
78
|
+
RELEASE_TRIGGER_NOTES_WRITE_FAILED,
|
|
79
|
+
RELEASE_TRIGGER_EVENT_EMIT_FAILED,
|
|
80
|
+
RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED,
|
|
81
|
+
RELEASE_TRIGGER_SOURCE_INVALID,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
FAIL_CODES_COUNT = 9
|
|
85
|
+
|
|
86
|
+
# --- TriggerContext dataclass ------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class TriggerContext:
|
|
91
|
+
version: str
|
|
92
|
+
previous_version: Optional[str]
|
|
93
|
+
source: str # manual | github | npm | git_tag
|
|
94
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- Abstract base class ------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ReleaseAdapter(abc.ABC):
|
|
101
|
+
"""Abstract base class for release trigger adapters."""
|
|
102
|
+
|
|
103
|
+
source_name: str = "unknown"
|
|
104
|
+
|
|
105
|
+
@abc.abstractmethod
|
|
106
|
+
def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
|
|
107
|
+
"""Return TriggerContext when this adapter matches, None otherwise."""
|
|
108
|
+
|
|
109
|
+
@abc.abstractmethod
|
|
110
|
+
def get_version_info(self) -> TriggerContext:
|
|
111
|
+
"""Resolve version + previous; fail-closed on missing data."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --- GitHub webhook adapter ---------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _strip_v(tag: str) -> str:
|
|
118
|
+
return tag.lstrip("v").lstrip("V")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_semver(tag: str) -> bool:
|
|
122
|
+
return bool(SEMVER_RE.match(tag.strip()))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _semver_sort_key(tag: str) -> Tuple[int, ...]:
|
|
126
|
+
base = _strip_v(tag).split("-", 1)[0]
|
|
127
|
+
parts = base.split(".")
|
|
128
|
+
out: List[int] = []
|
|
129
|
+
for p in parts:
|
|
130
|
+
try:
|
|
131
|
+
out.append(int(p))
|
|
132
|
+
except ValueError:
|
|
133
|
+
out.append(0)
|
|
134
|
+
while len(out) < 3:
|
|
135
|
+
out.append(0)
|
|
136
|
+
return tuple(out)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class GithubReleaseAdapter(ReleaseAdapter):
|
|
140
|
+
"""GitHub webhook release event adapter (AC-2).
|
|
141
|
+
|
|
142
|
+
Parse release.tag_name from webhook payload; query GitHub API to find
|
|
143
|
+
previous tag (sorted by created_at desc, skip current). Fallback:
|
|
144
|
+
git ls-remote --tags origin filtered for semver.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
source_name = "github"
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
152
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
153
|
+
timeout_sec: int = RELEASE_TRIGGER_TIMEOUT_SEC_DEFAULT,
|
|
154
|
+
) -> None:
|
|
155
|
+
self.env = env_vars or dict(os.environ)
|
|
156
|
+
self.payload = payload or {}
|
|
157
|
+
self.timeout_sec = timeout_sec
|
|
158
|
+
|
|
159
|
+
def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
|
|
160
|
+
env = env_vars or self.env
|
|
161
|
+
if env.get("GITHUB_EVENT_NAME") == "release" and env.get("GITHUB_EVENT_PATH"):
|
|
162
|
+
try:
|
|
163
|
+
with open(env["GITHUB_EVENT_PATH"], "r", encoding="utf-8") as f:
|
|
164
|
+
payload = json.load(f)
|
|
165
|
+
tag = payload.get("release", {}).get("tag_name", "")
|
|
166
|
+
if tag and _is_semver(tag):
|
|
167
|
+
return self._resolve(tag, env)
|
|
168
|
+
except (OSError, json.JSONDecodeError):
|
|
169
|
+
return None
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def get_version_info(self) -> TriggerContext:
|
|
173
|
+
tag = self.payload.get("release", {}).get("tag_name", "")
|
|
174
|
+
if not tag or not _is_semver(tag):
|
|
175
|
+
raise ReleaseTriggerError(
|
|
176
|
+
RELEASE_TRIGGER_TAG_MISSING, "GitHub payload missing release.tag_name"
|
|
177
|
+
)
|
|
178
|
+
return self._resolve(tag, self.env)
|
|
179
|
+
|
|
180
|
+
def _resolve(self, tag: str, env: Dict[str, str]) -> TriggerContext:
|
|
181
|
+
version = _strip_v(tag)
|
|
182
|
+
previous = self._find_previous(env, version)
|
|
183
|
+
return TriggerContext(
|
|
184
|
+
version=version,
|
|
185
|
+
previous_version=previous,
|
|
186
|
+
source=self.source_name,
|
|
187
|
+
metadata={"tag_name": tag, "token_env_ref": "GITHUB_TOKEN"},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _find_previous(self, env: Dict[str, str], current_version: str) -> Optional[str]:
|
|
191
|
+
token = env.get("GITHUB_TOKEN", "")
|
|
192
|
+
repo_slug = env.get("GITHUB_REPOSITORY", "")
|
|
193
|
+
if token and repo_slug:
|
|
194
|
+
tags = self._api_tags(token, repo_slug, env)
|
|
195
|
+
if tags is not None:
|
|
196
|
+
for t in tags:
|
|
197
|
+
if _is_semver(t) and _strip_v(t) != current_version:
|
|
198
|
+
return _strip_v(t)
|
|
199
|
+
return None
|
|
200
|
+
tags = self._ls_remote_tags(env)
|
|
201
|
+
for t in tags:
|
|
202
|
+
if _is_semver(t) and _strip_v(t) != current_version:
|
|
203
|
+
return _strip_v(t)
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def _api_tags(
|
|
207
|
+
self, token: str, repo_slug: str, env: Dict[str, str]
|
|
208
|
+
) -> Optional[List[str]]:
|
|
209
|
+
import urllib.request
|
|
210
|
+
import urllib.error
|
|
211
|
+
|
|
212
|
+
url = f"https://api.github.com/repos/{repo_slug}/releases?per_page=100"
|
|
213
|
+
req = urllib.request.Request(url)
|
|
214
|
+
req.add_header("Authorization", f"token {token}")
|
|
215
|
+
req.add_header("Accept", "application/vnd.github.v3+json")
|
|
216
|
+
try:
|
|
217
|
+
with urllib.request.urlopen(req, timeout=self.timeout_sec) as resp:
|
|
218
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
219
|
+
releases = [(r["tag_name"], r.get("created_at", "")) for r in data if "tag_name" in r]
|
|
220
|
+
releases.sort(key=lambda x: x[1], reverse=True)
|
|
221
|
+
return [r[0] for r in releases]
|
|
222
|
+
except Exception:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
def _ls_remote_tags(self, env: Dict[str, str]) -> List[str]:
|
|
226
|
+
try:
|
|
227
|
+
result = subprocess.run(
|
|
228
|
+
["git", "ls-remote", "--tags", "origin"],
|
|
229
|
+
capture_output=True,
|
|
230
|
+
text=True,
|
|
231
|
+
timeout=self.timeout_sec,
|
|
232
|
+
cwd=env.get("GIT_WORK_DIR", None),
|
|
233
|
+
)
|
|
234
|
+
if result.returncode != 0:
|
|
235
|
+
return []
|
|
236
|
+
tags: List[str] = []
|
|
237
|
+
for line in result.stdout.splitlines():
|
|
238
|
+
parts = line.split("\t")
|
|
239
|
+
if len(parts) >= 2 and parts[1].startswith("refs/tags/"):
|
|
240
|
+
tag = parts[1][len("refs/tags/") :]
|
|
241
|
+
if not tag.endswith("^{}") and _is_semver(tag):
|
|
242
|
+
tags.append(tag)
|
|
243
|
+
tags.sort(key=_semver_sort_key, reverse=True)
|
|
244
|
+
return tags
|
|
245
|
+
except Exception:
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# --- npm publish adapter ------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class NpmPublishAdapter(ReleaseAdapter):
|
|
253
|
+
"""npm publish event adapter (AC-3).
|
|
254
|
+
|
|
255
|
+
Read npm_package_version env var; query npm registry for previous version.
|
|
256
|
+
Offline fallback: package-lock.json when RELEASE_TRIGGER_FALLBACK_TO_LOCAL=1.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
source_name = "npm"
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
264
|
+
timeout_sec: int = RELEASE_TRIGGER_TIMEOUT_SEC_DEFAULT,
|
|
265
|
+
fallback_to_local: bool = False,
|
|
266
|
+
repo_root: Optional[str] = None,
|
|
267
|
+
) -> None:
|
|
268
|
+
self.env = env_vars or dict(os.environ)
|
|
269
|
+
self.timeout_sec = timeout_sec
|
|
270
|
+
self.fallback_to_local = fallback_to_local
|
|
271
|
+
self.repo_root = repo_root or "."
|
|
272
|
+
|
|
273
|
+
def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
|
|
274
|
+
env = env_vars or self.env
|
|
275
|
+
pkg_version = env.get("npm_package_version", "")
|
|
276
|
+
if pkg_version and _is_semver(pkg_version):
|
|
277
|
+
return self._resolve(pkg_version, env)
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
def get_version_info(self) -> TriggerContext:
|
|
281
|
+
pkg_version = self.env.get("npm_package_version", "")
|
|
282
|
+
if not pkg_version or not _is_semver(pkg_version):
|
|
283
|
+
pkg_lock = self._read_package_lock()
|
|
284
|
+
if pkg_lock:
|
|
285
|
+
return TriggerContext(
|
|
286
|
+
version=pkg_lock,
|
|
287
|
+
previous_version=None,
|
|
288
|
+
source=self.source_name,
|
|
289
|
+
metadata={"fallback": "package-lock.json"},
|
|
290
|
+
)
|
|
291
|
+
raise ReleaseTriggerError(
|
|
292
|
+
RELEASE_TRIGGER_PACKAGE_JSON_MISSING,
|
|
293
|
+
"npm_package_version env var missing and package-lock.json unreadable",
|
|
294
|
+
)
|
|
295
|
+
return self._resolve(pkg_version, self.env)
|
|
296
|
+
|
|
297
|
+
def _resolve(self, version: str, env: Dict[str, str]) -> TriggerContext:
|
|
298
|
+
previous = self._find_previous(env, version)
|
|
299
|
+
return TriggerContext(
|
|
300
|
+
version=version,
|
|
301
|
+
previous_version=previous,
|
|
302
|
+
source=self.source_name,
|
|
303
|
+
metadata={"npm_package_version": version},
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _find_previous(self, env: Dict[str, str], current: str) -> Optional[str]:
|
|
307
|
+
versions = self._registry_versions(env)
|
|
308
|
+
if versions is not None:
|
|
309
|
+
sv_versions = [v for v in versions if _is_semver(v)]
|
|
310
|
+
sv_versions.sort(key=_semver_sort_key, reverse=True)
|
|
311
|
+
for v in sv_versions:
|
|
312
|
+
if v != current and v != _strip_v(current):
|
|
313
|
+
return v
|
|
314
|
+
return None
|
|
315
|
+
if self.fallback_to_local:
|
|
316
|
+
return self._read_package_lock()
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
def _registry_versions(self, env: Dict[str, str]) -> Optional[List[str]]:
|
|
320
|
+
pkg_name = env.get("npm_package_name", "")
|
|
321
|
+
if not pkg_name:
|
|
322
|
+
return None
|
|
323
|
+
try:
|
|
324
|
+
result = subprocess.run(
|
|
325
|
+
["npm", "view", pkg_name, "versions", "--json"],
|
|
326
|
+
capture_output=True,
|
|
327
|
+
text=True,
|
|
328
|
+
timeout=self.timeout_sec,
|
|
329
|
+
)
|
|
330
|
+
if result.returncode != 0:
|
|
331
|
+
return None
|
|
332
|
+
data = json.loads(result.stdout)
|
|
333
|
+
if isinstance(data, list):
|
|
334
|
+
return data
|
|
335
|
+
return None
|
|
336
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
def _read_package_lock(self) -> Optional[str]:
|
|
340
|
+
lock_path = os.path.join(self.repo_root, "package-lock.json")
|
|
341
|
+
if not os.path.isfile(lock_path):
|
|
342
|
+
return None
|
|
343
|
+
try:
|
|
344
|
+
with open(lock_path, "r", encoding="utf-8") as f:
|
|
345
|
+
data = json.load(f)
|
|
346
|
+
version = data.get("version", "")
|
|
347
|
+
if version and _is_semver(version):
|
|
348
|
+
return version
|
|
349
|
+
except (OSError, json.JSONDecodeError):
|
|
350
|
+
pass
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --- Git tag push adapter -----------------------------------------------------
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class GitTagAdapter(ReleaseAdapter):
|
|
358
|
+
"""Git tag push adapter (AC-4).
|
|
359
|
+
|
|
360
|
+
Parse GITHUB_REF (CI) or local git describe --tags --abbrev=0.
|
|
361
|
+
Compute previous_version via git for-each-ref --sort=-version:refname refs/tags.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
source_name = "git_tag"
|
|
365
|
+
|
|
366
|
+
def __init__(
|
|
367
|
+
self,
|
|
368
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
369
|
+
repo_root: Optional[str] = None,
|
|
370
|
+
) -> None:
|
|
371
|
+
self.env = env_vars or dict(os.environ)
|
|
372
|
+
self.repo_root = repo_root or "."
|
|
373
|
+
|
|
374
|
+
def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
|
|
375
|
+
env = env_vars or self.env
|
|
376
|
+
ref = env.get("GITHUB_REF", "")
|
|
377
|
+
if ref.startswith("refs/tags/"):
|
|
378
|
+
tag = ref[len("refs/tags/") :]
|
|
379
|
+
if _is_semver(tag):
|
|
380
|
+
return self._resolve(tag)
|
|
381
|
+
try:
|
|
382
|
+
tag = self._git_describe_tag()
|
|
383
|
+
if tag and _is_semver(tag):
|
|
384
|
+
return self._resolve(tag)
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
def get_version_info(self) -> TriggerContext:
|
|
390
|
+
ref = self.env.get("GITHUB_REF", "")
|
|
391
|
+
if ref.startswith("refs/tags/"):
|
|
392
|
+
tag = ref[len("refs/tags/") :]
|
|
393
|
+
if _is_semver(tag):
|
|
394
|
+
return self._resolve(tag)
|
|
395
|
+
raise ReleaseTriggerError(
|
|
396
|
+
RELEASE_TRIGGER_TAG_MISSING, f"GITHUB_REF tag is not semver: {ref!r}"
|
|
397
|
+
)
|
|
398
|
+
tag = self._git_describe_tag()
|
|
399
|
+
if tag and _is_semver(tag):
|
|
400
|
+
return self._resolve(tag)
|
|
401
|
+
raise ReleaseTriggerError(
|
|
402
|
+
RELEASE_TRIGGER_TAG_MISSING, "Cannot resolve current tag from GITHUB_REF or git describe"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _resolve(self, tag: str) -> TriggerContext:
|
|
406
|
+
version = _strip_v(tag)
|
|
407
|
+
previous = self._find_previous()
|
|
408
|
+
return TriggerContext(
|
|
409
|
+
version=version,
|
|
410
|
+
previous_version=previous,
|
|
411
|
+
source=self.source_name,
|
|
412
|
+
metadata={"tag_name": tag},
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def _git_describe_tag(self) -> Optional[str]:
|
|
416
|
+
try:
|
|
417
|
+
result = subprocess.run(
|
|
418
|
+
["git", "describe", "--tags", "--abbrev=0"],
|
|
419
|
+
capture_output=True,
|
|
420
|
+
text=True,
|
|
421
|
+
cwd=self.repo_root,
|
|
422
|
+
)
|
|
423
|
+
if result.returncode == 0:
|
|
424
|
+
return result.stdout.strip()
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
def _find_previous(self) -> Optional[str]:
|
|
430
|
+
try:
|
|
431
|
+
result = subprocess.run(
|
|
432
|
+
["git", "for-each-ref", "--sort=-version:refname", "refs/tags"],
|
|
433
|
+
capture_output=True,
|
|
434
|
+
text=True,
|
|
435
|
+
cwd=self.repo_root,
|
|
436
|
+
)
|
|
437
|
+
if result.returncode != 0:
|
|
438
|
+
return None
|
|
439
|
+
current = None
|
|
440
|
+
ref = self.env.get("GITHUB_REF", "")
|
|
441
|
+
if ref.startswith("refs/tags/"):
|
|
442
|
+
current = _strip_v(ref[len("refs/tags/") :])
|
|
443
|
+
for line in result.stdout.splitlines():
|
|
444
|
+
parts = line.split()
|
|
445
|
+
if len(parts) >= 3:
|
|
446
|
+
refname = parts[2]
|
|
447
|
+
if refname.startswith("refs/tags/"):
|
|
448
|
+
tag = refname[len("refs/tags/") :]
|
|
449
|
+
if _is_semver(tag):
|
|
450
|
+
v = _strip_v(tag)
|
|
451
|
+
if current is not None and v == current:
|
|
452
|
+
continue
|
|
453
|
+
return v
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# --- Manual adapter (backward compatibility) ----------------------------------
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class ManualReleaseAdapter(ReleaseAdapter):
|
|
463
|
+
"""Manual /release adapter (AC-5).
|
|
464
|
+
|
|
465
|
+
Byte-identical to pre-US-0111 /release behavior.
|
|
466
|
+
source=manual, version=current, previous_version=None.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
source_name = "manual"
|
|
470
|
+
|
|
471
|
+
def __init__(
|
|
472
|
+
self,
|
|
473
|
+
current_version: Optional[str] = None,
|
|
474
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
475
|
+
repo_root: Optional[str] = None,
|
|
476
|
+
) -> None:
|
|
477
|
+
self.current_version = current_version
|
|
478
|
+
self.env = env_vars or dict(os.environ)
|
|
479
|
+
self.repo_root = repo_root or "."
|
|
480
|
+
|
|
481
|
+
def detect(self, env_vars: Optional[Dict[str, str]] = None) -> Optional[TriggerContext]:
|
|
482
|
+
return TriggerContext(
|
|
483
|
+
version=self.current_version or "0.0.0",
|
|
484
|
+
previous_version=None,
|
|
485
|
+
source=self.source_name,
|
|
486
|
+
metadata={"manual": True},
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def get_version_info(self) -> TriggerContext:
|
|
490
|
+
return TriggerContext(
|
|
491
|
+
version=self.current_version or "0.0.0",
|
|
492
|
+
previous_version=None,
|
|
493
|
+
source=self.source_name,
|
|
494
|
+
metadata={"manual": True},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# --- Adapter registry ---------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
_ADAPTER_MAP: Dict[str, type] = {
|
|
501
|
+
"github": GithubReleaseAdapter,
|
|
502
|
+
"npm": NpmPublishAdapter,
|
|
503
|
+
"git_tag": GitTagAdapter,
|
|
504
|
+
"manual": ManualReleaseAdapter,
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
# Auto-detection priority order
|
|
508
|
+
_AUTO_PRIORITY: Tuple[str, ...] = ("github", "npm", "git_tag", "manual")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class ReleaseTriggerError(Exception):
|
|
512
|
+
def __init__(self, code: str, message: str = "") -> None:
|
|
513
|
+
self.code = code
|
|
514
|
+
super().__init__(message or code)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def dispatch_to_adapter(
|
|
518
|
+
source: str,
|
|
519
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
520
|
+
*,
|
|
521
|
+
current_version: Optional[str] = None,
|
|
522
|
+
repo_root: Optional[str] = None,
|
|
523
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
524
|
+
) -> TriggerContext:
|
|
525
|
+
"""
|
|
526
|
+
Resolve source and dispatch to the matching adapter. Returns TriggerContext.
|
|
527
|
+
|
|
528
|
+
source values: manual, github, npm, git_tag, auto
|
|
529
|
+
Invalid source → RELEASE_TRIGGER_SOURCE_INVALID (fail-closed).
|
|
530
|
+
"""
|
|
531
|
+
if source not in RELEASE_TRIGGER_SOURCE_VALUES:
|
|
532
|
+
raise ReleaseTriggerError(
|
|
533
|
+
RELEASE_TRIGGER_SOURCE_INVALID,
|
|
534
|
+
f"Unknown RELEASE_TRIGGER_SOURCE={source!r}; "
|
|
535
|
+
f"allowed: {sorted(RELEASE_TRIGGER_SOURCE_VALUES)}",
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
env = env_vars or dict(os.environ)
|
|
539
|
+
|
|
540
|
+
if source == "auto":
|
|
541
|
+
return _auto_dispatch(env, current_version=current_version, repo_root=repo_root, payload=payload)
|
|
542
|
+
|
|
543
|
+
adapter = _instantiate(source, env, current_version, repo_root, payload)
|
|
544
|
+
return adapter.get_version_info()
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _auto_dispatch(
|
|
548
|
+
env: Dict[str, str],
|
|
549
|
+
*,
|
|
550
|
+
current_version: Optional[str] = None,
|
|
551
|
+
repo_root: Optional[str] = None,
|
|
552
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
553
|
+
) -> TriggerContext:
|
|
554
|
+
for source_name in _AUTO_PRIORITY:
|
|
555
|
+
if source_name == "manual":
|
|
556
|
+
continue
|
|
557
|
+
adapter = _instantiate(source_name, env, current_version, repo_root, payload)
|
|
558
|
+
ctx = adapter.detect(env)
|
|
559
|
+
if ctx is not None:
|
|
560
|
+
return ctx
|
|
561
|
+
return ManualReleaseAdapter(
|
|
562
|
+
current_version=current_version,
|
|
563
|
+
env_vars=env,
|
|
564
|
+
repo_root=repo_root,
|
|
565
|
+
).get_version_info()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _instantiate(
|
|
569
|
+
source: str,
|
|
570
|
+
env: Dict[str, str],
|
|
571
|
+
current_version: Optional[str],
|
|
572
|
+
repo_root: Optional[str],
|
|
573
|
+
payload: Optional[Dict[str, Any]],
|
|
574
|
+
) -> ReleaseAdapter:
|
|
575
|
+
cls = _ADAPTER_MAP[source]
|
|
576
|
+
if source == "github":
|
|
577
|
+
return cls(env_vars=env, payload=payload or {})
|
|
578
|
+
if source == "npm":
|
|
579
|
+
return cls(env_vars=env, repo_root=repo_root)
|
|
580
|
+
if source == "git_tag":
|
|
581
|
+
return cls(env_vars=env, repo_root=repo_root)
|
|
582
|
+
if source == "manual":
|
|
583
|
+
return cls(current_version=current_version, env_vars=env, repo_root=repo_root)
|
|
584
|
+
return cls()
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# --- Version comparison integration (T-006 / AC-6) ---------------------------
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def compare_versions_from_trigger(trigger: TriggerContext) -> Tuple[str, Optional[str]]:
|
|
591
|
+
"""
|
|
592
|
+
Compute semver diff from TriggerContext.
|
|
593
|
+
Uses release_changelog_lib.normalize_semver for validation.
|
|
594
|
+
Fail-closed: RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED.
|
|
595
|
+
"""
|
|
596
|
+
try:
|
|
597
|
+
from release_changelog_lib import normalize_semver, ReleaseChangelogError
|
|
598
|
+
except ImportError:
|
|
599
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
600
|
+
from release_changelog_lib import normalize_semver, ReleaseChangelogError
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
norm_current = normalize_semver(trigger.version)
|
|
604
|
+
except ReleaseChangelogError as exc:
|
|
605
|
+
raise ReleaseTriggerError(
|
|
606
|
+
RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED,
|
|
607
|
+
f"Cannot normalize current version: {exc}",
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
norm_previous: Optional[str] = None
|
|
611
|
+
if trigger.previous_version:
|
|
612
|
+
try:
|
|
613
|
+
norm_previous = normalize_semver(trigger.previous_version)
|
|
614
|
+
except ReleaseChangelogError as exc:
|
|
615
|
+
raise ReleaseTriggerError(
|
|
616
|
+
RELEASE_TRIGGER_COMPARE_VERSIONS_FAILED,
|
|
617
|
+
f"Cannot normalize previous version: {exc}",
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
return norm_current, norm_previous
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
# --- Atomic file write (T-007, T-008 / AC-7, AC-8) ---------------------------
|
|
624
|
+
|
|
625
|
+
_ATOMIC_RETRY_DELAY = 0.1
|
|
626
|
+
_ATOMIC_RETRY_COUNT = 2
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def atomic_write_file(target_path: str, content: str) -> None:
|
|
630
|
+
"""
|
|
631
|
+
Write content to target_path atomically via os.replace(temp, target).
|
|
632
|
+
Best-effort on Windows: catch PermissionError, retry 0.1s.
|
|
633
|
+
Fail-closed: RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED.
|
|
634
|
+
"""
|
|
635
|
+
target_dir = os.path.dirname(target_path) or "."
|
|
636
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
637
|
+
|
|
638
|
+
for attempt in range(_ATOMIC_RETRY_COUNT + 1):
|
|
639
|
+
try:
|
|
640
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
641
|
+
dir=target_dir, suffix=".tmp", prefix=".atomic_"
|
|
642
|
+
)
|
|
643
|
+
try:
|
|
644
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
|
645
|
+
f.write(content)
|
|
646
|
+
os.replace(tmp_path, target_path)
|
|
647
|
+
return
|
|
648
|
+
except Exception:
|
|
649
|
+
try:
|
|
650
|
+
os.unlink(tmp_path)
|
|
651
|
+
except OSError:
|
|
652
|
+
pass
|
|
653
|
+
raise
|
|
654
|
+
except PermissionError:
|
|
655
|
+
if attempt < _ATOMIC_RETRY_COUNT:
|
|
656
|
+
time.sleep(_ATOMIC_RETRY_DELAY)
|
|
657
|
+
continue
|
|
658
|
+
raise ReleaseTriggerError(
|
|
659
|
+
RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED,
|
|
660
|
+
f"Atomic write failed (PermissionError after retries): {target_path}",
|
|
661
|
+
)
|
|
662
|
+
except ReleaseTriggerError:
|
|
663
|
+
raise
|
|
664
|
+
except Exception as exc:
|
|
665
|
+
raise ReleaseTriggerError(
|
|
666
|
+
RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED,
|
|
667
|
+
f"Atomic write failed: {target_path}: {exc}",
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def promote_changelog_version(
|
|
672
|
+
semver: str,
|
|
673
|
+
sprint_ids: Sequence[str],
|
|
674
|
+
repo_root: str,
|
|
675
|
+
release_date: Optional[str] = None,
|
|
676
|
+
) -> str:
|
|
677
|
+
"""
|
|
678
|
+
Promote [Unreleased] → [X.Y.Z] atomically + generate per-version doc.
|
|
679
|
+
Reuses release_changelog_lib.promote_unreleased() without modification.
|
|
680
|
+
Fail-closed: RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED.
|
|
681
|
+
"""
|
|
682
|
+
from release_changelog_lib import (
|
|
683
|
+
promote_unreleased,
|
|
684
|
+
ensure_changelog_stub,
|
|
685
|
+
changelog_path,
|
|
686
|
+
read_utf8,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
try:
|
|
690
|
+
ensure_changelog_stub(repo_root)
|
|
691
|
+
changelog = changelog_path(repo_root)
|
|
692
|
+
old_text = read_utf8(changelog)
|
|
693
|
+
|
|
694
|
+
promote_unreleased(semver, sprint_ids, repo_root, release_date)
|
|
695
|
+
|
|
696
|
+
new_text = read_utf8(changelog)
|
|
697
|
+
if new_text != old_text:
|
|
698
|
+
atomic_write_file(changelog, new_text)
|
|
699
|
+
|
|
700
|
+
return changelog
|
|
701
|
+
except ReleaseTriggerError:
|
|
702
|
+
raise
|
|
703
|
+
except Exception as exc:
|
|
704
|
+
raise ReleaseTriggerError(
|
|
705
|
+
RELEASE_TRIGGER_ATOMIC_PROMOTION_FAILED,
|
|
706
|
+
f"CHANGELOG promotion failed: {exc}",
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def write_per_version_notes(
|
|
711
|
+
semver: str,
|
|
712
|
+
sprint_ids: Sequence[str],
|
|
713
|
+
repo_root: str,
|
|
714
|
+
) -> str:
|
|
715
|
+
"""
|
|
716
|
+
Write handoffs/releases/vX.Y.Z-release-notes.md atomically.
|
|
717
|
+
Reuses release_changelog_lib.build_version_doc() read-only compose.
|
|
718
|
+
Fail-closed: RELEASE_TRIGGER_NOTES_WRITE_FAILED.
|
|
719
|
+
"""
|
|
720
|
+
from release_changelog_lib import (
|
|
721
|
+
build_version_doc,
|
|
722
|
+
version_doc_path,
|
|
723
|
+
normalize_semver,
|
|
724
|
+
derive_work_items,
|
|
725
|
+
version_fingerprint,
|
|
726
|
+
ReleaseChangelogError,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
try:
|
|
730
|
+
norm = normalize_semver(semver)
|
|
731
|
+
work_items = derive_work_items(sprint_ids, repo_root)
|
|
732
|
+
fp = version_fingerprint(norm, [w.item_id for w in work_items])
|
|
733
|
+
target_path = version_doc_path(repo_root, norm)
|
|
734
|
+
|
|
735
|
+
content_lines = [
|
|
736
|
+
f"# Release notes — {norm}",
|
|
737
|
+
"",
|
|
738
|
+
f"<!-- release_changelog_fingerprint: {fp} -->",
|
|
739
|
+
"",
|
|
740
|
+
"> Per-version GitHub `-F` SOT (US-0100). Sprint-scoped evidence in "
|
|
741
|
+
"`handoffs/releases/Sxxxx-release-notes.md`.",
|
|
742
|
+
"",
|
|
743
|
+
"## Work items",
|
|
744
|
+
"",
|
|
745
|
+
]
|
|
746
|
+
for wi in work_items:
|
|
747
|
+
content_lines.append(f"- **{wi.item_id}** — {wi.summary}")
|
|
748
|
+
content_lines.extend(["", "## Sprint evidence", ""])
|
|
749
|
+
for sid in sorted(set(sprint_ids)):
|
|
750
|
+
content_lines.append(f"- [`{sid}`](handoffs/releases/{sid}-release-notes.md)")
|
|
751
|
+
content_lines.append("")
|
|
752
|
+
content = "\n".join(content_lines)
|
|
753
|
+
|
|
754
|
+
atomic_write_file(target_path, content)
|
|
755
|
+
return target_path
|
|
756
|
+
except ReleaseTriggerError:
|
|
757
|
+
raise
|
|
758
|
+
except Exception as exc:
|
|
759
|
+
raise ReleaseTriggerError(
|
|
760
|
+
RELEASE_TRIGGER_NOTES_WRITE_FAILED,
|
|
761
|
+
f"Per-version notes write failed for {semver}: {exc}",
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# --- Ledger event emit (T-009 / AC-9) -----------------------------------------
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def emit_version_derivation_event(
|
|
769
|
+
trigger: TriggerContext,
|
|
770
|
+
norm_version: str,
|
|
771
|
+
norm_previous: Optional[str],
|
|
772
|
+
repo_root: str,
|
|
773
|
+
scratchpad: Optional[Dict[str, str]] = None,
|
|
774
|
+
) -> Dict[str, Any]:
|
|
775
|
+
"""
|
|
776
|
+
Emit (semver, previous_semver, timestamp, derivation_decisions[]) event
|
|
777
|
+
to US-0103 ledger via append_entry(decision_type='version_derivation').
|
|
778
|
+
Also write handoffs/release_events/{iso-timestamp}-{semver}.json.
|
|
779
|
+
Ledger schema unchanged (consumer-only append compose).
|
|
780
|
+
Fail-closed: RELEASE_TRIGGER_EVENT_EMIT_FAILED.
|
|
781
|
+
"""
|
|
782
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
783
|
+
iso_compact = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
784
|
+
|
|
785
|
+
derivation_decisions: List[str] = []
|
|
786
|
+
if trigger.source != "manual":
|
|
787
|
+
derivation_decisions.append(f"trigger_source={trigger.source}")
|
|
788
|
+
if norm_previous:
|
|
789
|
+
derivation_decisions.append(f"previous={norm_previous}")
|
|
790
|
+
|
|
791
|
+
event_payload = {
|
|
792
|
+
"semver": norm_version,
|
|
793
|
+
"previous_semver": norm_previous,
|
|
794
|
+
"timestamp_iso": ts,
|
|
795
|
+
"derivation_decisions": derivation_decisions,
|
|
796
|
+
"source": trigger.source,
|
|
797
|
+
"metadata": trigger.metadata,
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
event_dir = os.path.join(repo_root, "handoffs", "release_events")
|
|
801
|
+
os.makedirs(event_dir, exist_ok=True)
|
|
802
|
+
event_filename = f"{iso_compact}-{norm_version}.json"
|
|
803
|
+
event_path = os.path.join(event_dir, event_filename)
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
atomic_write_file(event_path, json.dumps(event_payload, indent=2, sort_keys=True) + "\n")
|
|
807
|
+
except ReleaseTriggerError:
|
|
808
|
+
raise
|
|
809
|
+
except Exception as exc:
|
|
810
|
+
raise ReleaseTriggerError(
|
|
811
|
+
RELEASE_TRIGGER_EVENT_EMIT_FAILED,
|
|
812
|
+
f"Failed to write release event file: {exc}",
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
try:
|
|
816
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
817
|
+
import decision_ledger_lib as ledger
|
|
818
|
+
|
|
819
|
+
ledger_path = Path(repo_root) / "handoffs" / "sovereign_decisions" / "decisions.jsonl"
|
|
820
|
+
ledger_entry = {
|
|
821
|
+
"ts": ts,
|
|
822
|
+
"orchestrator_run_id": (scratchpad or {}).get("ORCHESTRATOR_RUN_ID", "manual"),
|
|
823
|
+
"phase_id": "release",
|
|
824
|
+
"role": "dev",
|
|
825
|
+
"decision_id": f"version-derivation-{iso_compact}-{norm_version}",
|
|
826
|
+
"decision_type": "version_derivation",
|
|
827
|
+
"from_artifact": f"trigger:{trigger.source}",
|
|
828
|
+
"to_artifact": event_path,
|
|
829
|
+
"rationale": json.dumps(event_payload, sort_keys=True),
|
|
830
|
+
"plan_fidelity": (scratchpad or {}).get("AUTO_PLAN_FIDELITY", "strict"),
|
|
831
|
+
"cross_model_reviewed": False,
|
|
832
|
+
"risk_tier": "low",
|
|
833
|
+
}
|
|
834
|
+
result = ledger.append_entry(ledger_path, ledger_entry, scratchpad=scratchpad)
|
|
835
|
+
except ReleaseTriggerError:
|
|
836
|
+
raise
|
|
837
|
+
except Exception as exc:
|
|
838
|
+
raise ReleaseTriggerError(
|
|
839
|
+
RELEASE_TRIGGER_EVENT_EMIT_FAILED,
|
|
840
|
+
f"Failed to append ledger entry: {exc}",
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
return {"event_path": event_path, "ledger_result": result, "payload": event_payload}
|