cdt-release 0.4.0__py3-none-any.whl

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.
Files changed (64) hide show
  1. cdt/__init__.py +1 -0
  2. cdt/__main__.py +3 -0
  3. cdt/agent_release.py +316 -0
  4. cdt/agent_release_worker.py +46 -0
  5. cdt/artifacts.py +37 -0
  6. cdt/cdt.schema.json +1512 -0
  7. cdt/cli.py +590 -0
  8. cdt/config.py +38 -0
  9. cdt/doctor.py +81 -0
  10. cdt/flows/__init__.py +5 -0
  11. cdt/flows/deploy_flow.py +50 -0
  12. cdt/flows/ios_flow.py +52 -0
  13. cdt/flows/prod_flow.py +236 -0
  14. cdt/flows/testing_flow.py +296 -0
  15. cdt/init_project.py +95 -0
  16. cdt/pipeline/__init__.py +15 -0
  17. cdt/pipeline/builtins.py +213 -0
  18. cdt/pipeline/config.py +254 -0
  19. cdt/pipeline/context.py +164 -0
  20. cdt/pipeline/executor.py +180 -0
  21. cdt/pipeline/planning.py +314 -0
  22. cdt/pipeline/preflight.py +53 -0
  23. cdt/pipeline/registry.py +196 -0
  24. cdt/pipeline/runner.py +180 -0
  25. cdt/pipeline/step.py +10 -0
  26. cdt/pipeline/validation.py +152 -0
  27. cdt/platforms/__init__.py +1 -0
  28. cdt/platforms/android.py +137 -0
  29. cdt/platforms/flutter_build.py +60 -0
  30. cdt/platforms/ios_flutter.py +66 -0
  31. cdt/platforms/ios_xcode.py +214 -0
  32. cdt/platforms/web.py +104 -0
  33. cdt/py.typed +0 -0
  34. cdt/runner.py +108 -0
  35. cdt/runs.py +243 -0
  36. cdt/schema.py +182 -0
  37. cdt/sdk.py +78 -0
  38. cdt/self_update.py +394 -0
  39. cdt/services/__init__.py +1 -0
  40. cdt/services/appstore.py +244 -0
  41. cdt/services/firebase.py +54 -0
  42. cdt/services/notify.py +112 -0
  43. cdt/services/tracker.py +43 -0
  44. cdt/sounds.py +76 -0
  45. cdt/steps/__init__.py +1 -0
  46. cdt/steps/android.py +73 -0
  47. cdt/steps/appstore.py +29 -0
  48. cdt/steps/artifact.py +29 -0
  49. cdt/steps/firebase.py +39 -0
  50. cdt/steps/flutter.py +32 -0
  51. cdt/steps/git.py +46 -0
  52. cdt/steps/hook.py +93 -0
  53. cdt/steps/ios.py +83 -0
  54. cdt/steps/notify.py +53 -0
  55. cdt/steps/tracker.py +19 -0
  56. cdt/steps/web.py +126 -0
  57. cdt/ui.py +120 -0
  58. cdt/versioning.py +60 -0
  59. cdt_release-0.4.0.dist-info/METADATA +215 -0
  60. cdt_release-0.4.0.dist-info/RECORD +64 -0
  61. cdt_release-0.4.0.dist-info/WHEEL +5 -0
  62. cdt_release-0.4.0.dist-info/entry_points.txt +2 -0
  63. cdt_release-0.4.0.dist-info/licenses/LICENSE +21 -0
  64. cdt_release-0.4.0.dist-info/top_level.txt +1 -0
cdt/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
cdt/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from cdt.cli import app
2
+
3
+ app()
cdt/agent_release.py ADDED
@@ -0,0 +1,316 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from .runs import (
14
+ RUN_SCHEMA_VERSION,
15
+ RunPaths,
16
+ create_run,
17
+ now,
18
+ read_json,
19
+ resolve_run,
20
+ write_exit_code,
21
+ write_json_atomic,
22
+ write_text_atomic,
23
+ )
24
+
25
+
26
+ def artifact_paths(pipeline: str) -> dict[str, Path]:
27
+ """Return legacy pipeline-named paths for compatibility with older callers."""
28
+ base = Path.cwd() / ".cdt"
29
+ stem = f"agent-release-{pipeline}"
30
+ return {
31
+ "dir": base,
32
+ "log": base / f"{stem}.log",
33
+ "pid": base / f"{stem}.pid",
34
+ "meta": base / f"{stem}.meta.json",
35
+ "exit": base / f"{stem}.exit",
36
+ "status": base / f"{stem}.status.json",
37
+ }
38
+
39
+
40
+ def start_release(
41
+ pipeline: str,
42
+ ids: list[str] | None = None,
43
+ *,
44
+ run_id: str | None = None,
45
+ confirm: str | None = None,
46
+ ) -> dict[str, Any]:
47
+ ids = ids or []
48
+ cwd = Path.cwd()
49
+ command = ["cdt", "run", pipeline]
50
+ for task_id in ids:
51
+ command.extend(["--id", task_id])
52
+ if confirm is not None:
53
+ command.extend(["--confirm", confirm])
54
+ paths = create_run(cwd, pipeline, ids=ids, run_id=run_id, command=command, detached=True)
55
+
56
+ worker_cmd = [
57
+ sys.executable,
58
+ "-m",
59
+ "cdt.agent_release_worker",
60
+ "--pipeline",
61
+ pipeline,
62
+ "--run-id",
63
+ paths.run_id,
64
+ "--log",
65
+ str(paths.log),
66
+ "--exit-file",
67
+ str(paths.exit),
68
+ "--status-file",
69
+ str(paths.status),
70
+ ]
71
+ for task_id in ids:
72
+ worker_cmd.extend(["--id", task_id])
73
+ if confirm is not None:
74
+ worker_cmd.extend(["--confirm", confirm])
75
+
76
+ try:
77
+ process = subprocess.Popen(
78
+ worker_cmd,
79
+ cwd=cwd,
80
+ start_new_session=True,
81
+ stdout=subprocess.DEVNULL,
82
+ stderr=subprocess.DEVNULL,
83
+ )
84
+ except Exception as exc:
85
+ payload = read_json(paths.status) or {}
86
+ payload.update({"status": "failed", "error": f"Failed to start release worker: {exc}", "finished_at": now()})
87
+ payload["updated_at"] = now()
88
+ write_json_atomic(paths.status, payload)
89
+ write_exit_code(paths.exit, 1)
90
+ return release_status(run_id=paths.run_id)
91
+
92
+ write_text_atomic(paths.pid, f"{process.pid}\n")
93
+ manifest = read_json(paths.manifest) or {}
94
+ manifest.update({"pid": process.pid, "worker_command": worker_cmd})
95
+ write_json_atomic(paths.manifest, manifest)
96
+ return release_status(run_id=paths.run_id)
97
+
98
+
99
+ def release_status(pipeline: str | None = None, *, run_id: str | None = None) -> dict[str, Any]:
100
+ paths, legacy = _resolve_paths(pipeline=pipeline, run_id=run_id)
101
+ if paths is None:
102
+ return {
103
+ "schema_version": RUN_SCHEMA_VERSION,
104
+ "status": "unknown",
105
+ "run_id": run_id,
106
+ "pipeline": pipeline,
107
+ "pid": None,
108
+ "exit_code": None,
109
+ "log": None,
110
+ "status_file": None,
111
+ "last_log_update": None,
112
+ }
113
+
114
+ pid = _read_pid(paths.pid)
115
+ exit_code = _read_exit(paths.exit)
116
+ running = _pid_running(pid) if pid is not None else False
117
+ status_payload = read_json(paths.status) or {}
118
+ manifest = read_json(paths.manifest) or {}
119
+ recorded_status = status_payload.get("status")
120
+
121
+ if exit_code == 0:
122
+ status = "success"
123
+ elif exit_code is not None:
124
+ status = "failed" if recorded_status != "cancelled" else "cancelled"
125
+ elif running:
126
+ status = "running"
127
+ elif recorded_status in {"success", "failed", "cancelled", "blocked"}:
128
+ status = str(recorded_status)
129
+ elif pid is not None:
130
+ status = "stale"
131
+ elif recorded_status == "queued":
132
+ status = "queued"
133
+ else:
134
+ status = "unknown"
135
+
136
+ payload: dict[str, Any] = {
137
+ "schema_version": RUN_SCHEMA_VERSION,
138
+ "status": status,
139
+ "run_id": None if legacy else paths.run_id,
140
+ "pipeline": status_payload.get("pipeline") or manifest.get("pipeline") or pipeline,
141
+ "pid": pid,
142
+ "exit_code": exit_code,
143
+ "log": str(paths.log),
144
+ "status_file": str(paths.status),
145
+ "last_log_update": _mtime(paths.log),
146
+ }
147
+ status_keys = (
148
+ "current_step",
149
+ "completed_steps",
150
+ "running_steps",
151
+ "parallel_completed",
152
+ "parallel_failed",
153
+ "failed_step",
154
+ "error",
155
+ "artifacts",
156
+ "old_version",
157
+ "new_version",
158
+ "started_at",
159
+ "finished_at",
160
+ "updated_at",
161
+ )
162
+ for key in status_keys:
163
+ if key in status_payload:
164
+ payload[key] = status_payload[key]
165
+ return payload
166
+
167
+
168
+ def wait_for_release(
169
+ pipeline: str | None = None,
170
+ timeout_seconds: int | None = None,
171
+ interval_seconds: float = 5.0,
172
+ *,
173
+ run_id: str | None = None,
174
+ ) -> dict[str, Any]:
175
+ deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
176
+ while True:
177
+ payload = release_status(pipeline, run_id=run_id)
178
+ if payload["status"] in {"success", "failed", "cancelled", "blocked", "stale", "unknown"}:
179
+ return payload
180
+ if deadline is not None and time.monotonic() >= deadline:
181
+ payload["wait_status"] = "timeout"
182
+ return payload
183
+ time.sleep(interval_seconds)
184
+
185
+
186
+ def stop_release(
187
+ pipeline: str | None = None,
188
+ timeout_seconds: int = 30,
189
+ *,
190
+ run_id: str | None = None,
191
+ ) -> dict[str, Any]:
192
+ paths, _ = _resolve_paths(pipeline=pipeline, run_id=run_id)
193
+ if paths is None:
194
+ payload = release_status(pipeline, run_id=run_id)
195
+ payload["stop_result"] = "missing_pid"
196
+ return payload
197
+ manifest = read_json(paths.manifest) or {}
198
+ if manifest.get("detached") is False:
199
+ payload = release_status(pipeline, run_id=run_id)
200
+ payload["stop_result"] = "not_detached"
201
+ return payload
202
+ pid = _read_pid(paths.pid)
203
+ if pid is None:
204
+ payload = release_status(pipeline, run_id=run_id)
205
+ payload["stop_result"] = "missing_pid"
206
+ return payload
207
+ if not _pid_running(pid):
208
+ payload = release_status(pipeline, run_id=run_id)
209
+ payload["stop_result"] = "not_running"
210
+ return payload
211
+
212
+ try:
213
+ os.killpg(pid, signal.SIGTERM)
214
+ except ProcessLookupError:
215
+ payload = release_status(pipeline, run_id=run_id)
216
+ payload["stop_result"] = "not_running"
217
+ return payload
218
+
219
+ deadline = time.monotonic() + timeout_seconds
220
+ while time.monotonic() < deadline:
221
+ if not _pid_running(pid):
222
+ _mark_cancelled(paths)
223
+ payload = release_status(pipeline, run_id=run_id)
224
+ payload["stop_result"] = "terminated"
225
+ return payload
226
+ time.sleep(0.5)
227
+
228
+ try:
229
+ os.killpg(pid, signal.SIGKILL)
230
+ except ProcessLookupError:
231
+ pass
232
+ _mark_cancelled(paths)
233
+ payload = release_status(pipeline, run_id=run_id)
234
+ payload["stop_result"] = "killed"
235
+ return payload
236
+
237
+
238
+ def parse_duration(value: str) -> int:
239
+ raw = value.strip().lower()
240
+ if raw.endswith("ms"):
241
+ return max(1, int(raw[:-2]) // 1000)
242
+ if raw.endswith("s"):
243
+ return int(raw[:-1])
244
+ if raw.endswith("m"):
245
+ return int(raw[:-1]) * 60
246
+ if raw.endswith("h"):
247
+ return int(raw[:-1]) * 3600
248
+ return int(raw)
249
+
250
+
251
+ def format_yamlish(payload: dict[str, Any]) -> str:
252
+ return yaml.safe_dump(payload, allow_unicode=True, sort_keys=False).strip()
253
+
254
+
255
+ def _resolve_paths(*, pipeline: str | None, run_id: str | None) -> tuple[RunPaths | None, bool]:
256
+ cwd = Path.cwd()
257
+ paths = resolve_run(cwd, run_id=run_id, pipeline=pipeline)
258
+ if paths is not None:
259
+ return paths, False
260
+ if pipeline is None:
261
+ return None, False
262
+ legacy = artifact_paths(pipeline)
263
+ if not any(legacy[key].exists() for key in ("pid", "exit", "status", "log")):
264
+ return None, False
265
+ return (
266
+ RunPaths(
267
+ run_id=pipeline,
268
+ root=legacy["dir"],
269
+ manifest=legacy["meta"],
270
+ status=legacy["status"],
271
+ log=legacy["log"],
272
+ exit=legacy["exit"],
273
+ pid=legacy["pid"],
274
+ ),
275
+ True,
276
+ )
277
+
278
+
279
+ def _mark_cancelled(paths: RunPaths) -> None:
280
+ payload = read_json(paths.status) or {}
281
+ payload.update({"status": "cancelled", "finished_at": now(), "updated_at": now()})
282
+ write_json_atomic(paths.status, payload)
283
+ write_exit_code(paths.exit, 130)
284
+
285
+
286
+ def _read_pid(path: Path) -> int | None:
287
+ try:
288
+ return int(path.read_text(encoding="utf-8").strip())
289
+ except (OSError, ValueError):
290
+ return None
291
+
292
+
293
+ def _read_exit(path: Path) -> int | None:
294
+ try:
295
+ return int(path.read_text(encoding="utf-8").strip())
296
+ except (OSError, ValueError):
297
+ return None
298
+
299
+
300
+ def _pid_running(pid: int | None) -> bool:
301
+ if pid is None:
302
+ return False
303
+ try:
304
+ os.kill(pid, 0)
305
+ except ProcessLookupError:
306
+ return False
307
+ except PermissionError:
308
+ return True
309
+ return True
310
+
311
+
312
+ def _mtime(path: Path) -> str | None:
313
+ try:
314
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(path.stat().st_mtime))
315
+ except OSError:
316
+ return None
@@ -0,0 +1,46 @@
1
+ import argparse
2
+ import subprocess
3
+ import sys
4
+ import traceback
5
+ from pathlib import Path
6
+
7
+
8
+ def main() -> int:
9
+ parser = argparse.ArgumentParser()
10
+ parser.add_argument("--pipeline", required=True)
11
+ parser.add_argument("--run-id")
12
+ parser.add_argument("--log", required=True)
13
+ parser.add_argument("--exit-file", required=True)
14
+ parser.add_argument("--status-file", required=True)
15
+ parser.add_argument("--id", action="append", default=[])
16
+ parser.add_argument("--confirm")
17
+ args = parser.parse_args()
18
+
19
+ cmd = [sys.executable, "-m", "cdt", "run", args.pipeline, "--status-file", args.status_file]
20
+ if args.run_id is not None:
21
+ cmd.extend(["--run-id", args.run_id])
22
+ for task_id in args.id:
23
+ cmd.extend(["--id", task_id])
24
+ if args.confirm is not None:
25
+ cmd.extend(["--confirm", args.confirm])
26
+
27
+ log_path = Path(args.log)
28
+ log_path.parent.mkdir(parents=True, exist_ok=True)
29
+ exit_file = Path(args.exit_file)
30
+ exit_file.parent.mkdir(parents=True, exist_ok=True)
31
+ exit_code = 1
32
+ try:
33
+ with log_path.open("ab") as log:
34
+ process = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT)
35
+ exit_code = process.wait()
36
+ except Exception:
37
+ with log_path.open("a", encoding="utf-8") as log:
38
+ log.write("\nagent_release_worker failed before or during cdt run startup:\n")
39
+ log.write(traceback.format_exc())
40
+ finally:
41
+ exit_file.write_text(f"{exit_code}\n", encoding="utf-8")
42
+ return exit_code
43
+
44
+
45
+ if __name__ == "__main__":
46
+ raise SystemExit(main())
cdt/artifacts.py ADDED
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from pathlib import Path
4
+
5
+
6
+ class ArtifactKind(str, Enum):
7
+ APK = "apk"
8
+ AAB = "aab"
9
+ IPA = "ipa"
10
+ WEB = "web"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class BuildArtifact:
15
+ kind: ArtifactKind
16
+ path: Path
17
+ label: str
18
+ step: str | None = None
19
+
20
+ def to_json(self, name: str) -> dict[str, str]:
21
+ payload = {
22
+ "name": name,
23
+ "path": str(self.path),
24
+ "kind": self.kind.value,
25
+ }
26
+ if self.step:
27
+ payload["step"] = self.step
28
+ return payload
29
+
30
+ @classmethod
31
+ def from_json(cls, payload: dict[str, str]) -> "BuildArtifact":
32
+ return cls(
33
+ kind=ArtifactKind(payload["kind"]),
34
+ path=Path(payload["path"]),
35
+ label=payload.get("label") or payload.get("name") or payload["kind"],
36
+ step=payload.get("step"),
37
+ )