coloph-sync 0.3.1__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.
@@ -0,0 +1 @@
1
+ """The supported public interface is the coloph-sync executable."""
coloph_sync/cli.py ADDED
@@ -0,0 +1,261 @@
1
+ """CLI and agent-facing status contract."""
2
+
3
+ import argparse
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ from .config import load_config
12
+ from .engine import Engine
13
+ from .hooks import check, install, uninstall
14
+ from .state import CommitState, read_state
15
+ from .storage import read_json, write_json
16
+
17
+ SKILLS = ("contributor", "operator", "finish")
18
+ CONFIG_TEMPLATE = """main_ref = "main"
19
+ remote = "origin"
20
+ commit_check = ["./scripts/check", "commit"]
21
+ merge_check = ["./scripts/check", "merge"]
22
+ integration_check = ["./scripts/check", "integration"]
23
+ deploy_command = ["./scripts/deploy"]
24
+ """
25
+
26
+
27
+ def _skill_files(root: Path):
28
+ return {
29
+ root / "skills" / f"coloph-sync-{name}" / "SKILL.md": files("coloph_sync")
30
+ .joinpath("skills", name, "SKILL.md")
31
+ .read_text(encoding="utf-8")
32
+ for name in SKILLS
33
+ }
34
+
35
+
36
+ def install_skills(root: Path):
37
+ skill_files = _skill_files(root)
38
+ for path, content in skill_files.items():
39
+ if path.exists() and path.read_text(encoding="utf-8") != content:
40
+ raise ValueError(f"Skill file differs: {path}; reconcile or move it before installing")
41
+ created = []
42
+ for path, content in skill_files.items():
43
+ if not path.exists():
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ path.write_text(content, encoding="utf-8")
46
+ created.append(path)
47
+ return created
48
+
49
+
50
+ def initialize(config: Path):
51
+ root = config.parent
52
+ skill_files = _skill_files(root)
53
+ for path, content in skill_files.items():
54
+ if path.exists() and path.read_text(encoding="utf-8") != content:
55
+ raise ValueError(f"Skill file differs: {path}; reconcile or move it before initializing")
56
+ created = []
57
+ if not config.exists():
58
+ config.write_text(CONFIG_TEMPLATE, encoding="utf-8")
59
+ created.append(config)
60
+ created.extend(install_skills(root))
61
+ return created
62
+
63
+
64
+ def status(engine, branch=None, commit=None):
65
+ git = engine.git
66
+ branch = branch or git.out("branch", "--show-current")
67
+ sha = git.resolve(commit or branch)
68
+ if not sha:
69
+ raise ValueError("Specify an existing branch or commit")
70
+ report = read_json(engine.report_path)
71
+ entry = report.get("branches", {}).get(branch, {})
72
+ main = git.resolve(engine.config.main_ref)
73
+ deployed = engine.deployed()
74
+ merged = bool(main and git.ancestor(sha, main))
75
+ delivered = bool(deployed and git.ancestor(sha, deployed))
76
+ state = read_state(git.message(sha))
77
+ reason = None
78
+ if not merged:
79
+ if state in (CommitState.WIP, CommitState.FAILED, CommitState.DONT_MERGE):
80
+ reason = state.value
81
+ elif entry.get("branch_sha") == sha:
82
+ reason = entry.get("merge_reason")
83
+ actionable = bool(reason and entry.get("reason_code") != "barrier")
84
+ error = report.get("error")
85
+ checks = report.get("checks_status")
86
+ verdict = (
87
+ "action needed"
88
+ if checks == "failed"
89
+ else "deployed"
90
+ if delivered
91
+ else "action needed"
92
+ if actionable or error
93
+ else "merged"
94
+ if merged
95
+ else "pending"
96
+ )
97
+ return {
98
+ "branch": branch,
99
+ "commit": sha,
100
+ "merged": merged,
101
+ "deployed": delivered,
102
+ "verdict": verdict,
103
+ "reason": reason,
104
+ "last_sync": report.get("timestamp"),
105
+ "phase": report.get("current_phase"),
106
+ "error": error,
107
+ "checks": checks,
108
+ "last_merge_attempt": entry.get("last_merge_attempt"),
109
+ "publication_pending": read_json(engine.delivery_path).get("attempt", {}).get("status") == "completed",
110
+ }
111
+
112
+
113
+ def render(value):
114
+ print(
115
+ f"Verdict: {value['verdict']}\nBranch: {value['branch']}\nMerged: {'yes' if value['merged'] else 'no'}\n"
116
+ f"Deployed: {'yes' if value['deployed'] else 'no'}"
117
+ )
118
+ print(f"Tip: {value['commit']}")
119
+ if value["reason"]:
120
+ print(f"Reason: {value['reason']}")
121
+ if value["phase"]:
122
+ print(f"Sync loop phase: {value['phase']}")
123
+ if value["checks"]:
124
+ print(f"Checks: {value['checks']}")
125
+ if value["error"]:
126
+ print(f"Last sync error: {value['error']['message']}")
127
+ if value["publication_pending"]:
128
+ print("Deployment confirmed; publication pending")
129
+
130
+
131
+ def main(argv=None):
132
+ parser = argparse.ArgumentParser(description="Commit checks, integration, and deployment coordination")
133
+ parser.add_argument("--config", type=Path)
134
+ parser.add_argument("--json", action="store_true")
135
+ sub = parser.add_subparsers(dest="command", required=True)
136
+ sub.add_parser("init", help="Create configuration and install host-project agent workflows")
137
+ run = sub.add_parser("run")
138
+ run.add_argument("--once", action="store_true")
139
+ run.add_argument("--branch", help="Restrict integration to one local worktree branch")
140
+ run.add_argument("--push-deploy-only", action="store_true", help="Skip merges, then check, push, and deploy main")
141
+ deploy = sub.add_parser("deploy", help="Deploy through the shared coordinator")
142
+ deploy.add_argument("--commit")
143
+ deploy.add_argument("--rollback", action="store_true", help="Explicit operator recovery; never used by the loop")
144
+ sub.add_parser("stop", help="Drain the current cycle and prevent the next cycle")
145
+ sub.add_parser("logs")
146
+ sub.add_parser("install-skills", help="Install agent workflows in the host project")
147
+ sub.add_parser("install-hooks")
148
+ sub.add_parser("uninstall-hooks")
149
+ sub.add_parser("message-state", help="Read a commit message from stdin and print its state")
150
+ hook = sub.add_parser("hook")
151
+ hook.add_argument("message", type=Path)
152
+ state = sub.add_parser("state", help="Read the typed commit state")
153
+ state.add_argument("ref", nargs="?", default="HEAD")
154
+ skill = sub.add_parser("skill", help="Print the bundled operating instructions")
155
+ skill.add_argument("name", choices=SKILLS)
156
+ for name in ("status", "wait"):
157
+ p = sub.add_parser(name)
158
+ p.add_argument("--branch")
159
+ p.add_argument("--commit")
160
+ p.add_argument("--all", action="store_true")
161
+ p.add_argument("--until", choices=["merged", "deployed"], default="deployed")
162
+ p.add_argument("--timeout", type=int, default=14400)
163
+ args = parser.parse_args(argv)
164
+ try:
165
+ if args.command == "init":
166
+ config = args.config.resolve() if args.config else Path.cwd() / "coloph-sync.toml"
167
+ root = config.parent
168
+ created = initialize(config)
169
+ if args.json:
170
+ print(json.dumps({"created": [str(path.relative_to(root)) for path in created]}))
171
+ else:
172
+ for path in created:
173
+ print(f"Created {path.relative_to(root)}")
174
+ if not created:
175
+ print("Project files are already initialized")
176
+ print("Install hooks after configuring real project commands: coloph-sync install-hooks")
177
+ return 0
178
+ if args.command == "message-state":
179
+ state = read_state(sys.stdin.read())
180
+ print(state.value if state else "unmarked")
181
+ return 0
182
+ if args.command == "skill":
183
+ print(files("coloph_sync").joinpath("skills", args.name, "SKILL.md").read_text())
184
+ return 0
185
+ if args.command == "install-skills":
186
+ root = args.config.resolve().parent if args.config else Path.cwd()
187
+ created = install_skills(root)
188
+ for path in created:
189
+ print(f"Installed {path.relative_to(root)}")
190
+ if not created:
191
+ print("Agent workflows are already installed")
192
+ return 0
193
+ config = load_config(args.config)
194
+ if args.command == "hook":
195
+ return check(config, args.message.resolve())
196
+ if args.command == "install-hooks":
197
+ install(config)
198
+ print("Installed commit-msg hook. Run coloph-sync init to install the agent workflows")
199
+ return 0
200
+ if args.command == "uninstall-hooks":
201
+ uninstall(config)
202
+ return 0
203
+ engine = Engine(config)
204
+ if args.command in ("run", "deploy"):
205
+ engine.manual_sha = getattr(args, "commit", None)
206
+ engine.rollback = getattr(args, "rollback", False)
207
+ engine.mode = args.command
208
+ engine.branch = getattr(args, "branch", None)
209
+ if engine.rollback and not engine.manual_sha:
210
+ raise ValueError("An explicit rollback requires --commit")
211
+ engine.run(
212
+ once=getattr(args, "once", False),
213
+ deploy_only=args.command == "deploy",
214
+ push_deploy_only=getattr(args, "push_deploy_only", False),
215
+ )
216
+ elif args.command == "stop":
217
+ owner = read_json(engine.owner_path)
218
+ if not owner:
219
+ print("No coordinator is running")
220
+ else:
221
+ write_json(engine.stop_path, {"id": owner["id"]})
222
+ print("Stop requested: the current cycle will finish")
223
+ elif args.command == "logs":
224
+ logs = sorted(engine.directory.glob("coloph-sync-*.log"), key=lambda path: path.stat().st_mtime)
225
+ if logs:
226
+ print(logs[-1].read_text(), end="")
227
+ elif args.command == "state":
228
+ value = read_state(engine.git.message(args.ref))
229
+ print(value.value if value else "unmarked")
230
+ else:
231
+ deadline = time.monotonic() + args.timeout
232
+ while True:
233
+ branches = (
234
+ engine.git.out("for-each-ref", "--format=%(refname:short)", "refs/heads/").splitlines()
235
+ if args.all
236
+ else [args.branch]
237
+ )
238
+ values = [status(engine, branch, args.commit) for branch in branches]
239
+ if args.json:
240
+ print(json.dumps(values if args.all else values[0]))
241
+ else:
242
+ for value in values:
243
+ render(value)
244
+ if args.command != "wait" or all(value[args.until] for value in values):
245
+ return 0
246
+ if any(value["verdict"] == "action needed" for value in values):
247
+ return 1
248
+ if time.monotonic() >= deadline:
249
+ return 1
250
+ time.sleep(min(5, max(0, deadline - time.monotonic())))
251
+ return 0
252
+ except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
253
+ print(str(exc), file=sys.stderr)
254
+ return 2
255
+ except KeyboardInterrupt:
256
+ print("Interrupted; reconcile any unfinished deploy attempt before resuming", file=sys.stderr)
257
+ return 130
258
+
259
+
260
+ if __name__ == "__main__":
261
+ raise SystemExit(main())
coloph_sync/config.py ADDED
@@ -0,0 +1,51 @@
1
+ """TOML configuration."""
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass, fields
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Config:
10
+ root: Path
11
+ commit_check: tuple[str, ...]
12
+ deploy_command: tuple[str, ...]
13
+ merge_check: tuple[str, ...] = ()
14
+ integration_check: tuple[str, ...] = ()
15
+ preflight_command: tuple[str, ...] = ()
16
+ main_ref: str = "main"
17
+ remote: str = "origin"
18
+ deployed_ref: str = "deployed"
19
+ deploy_tag_prefix: str = "deploy"
20
+ check_timeout: int = 14400
21
+ deploy_timeout: int = 14400
22
+ merge_timeout: int = 1500
23
+ interval: int = 60
24
+
25
+
26
+ def load_config(path: Path | None = None) -> Config:
27
+ path = path.resolve() if path else Path.cwd() / "coloph-sync.toml"
28
+ root = path.parent
29
+ with path.open("rb") as stream:
30
+ raw = tomllib.load(stream)
31
+ local = root / "coloph-sync.local.toml"
32
+ if local.exists() and local != path:
33
+ with local.open("rb") as stream:
34
+ raw.update(tomllib.load(stream))
35
+ unknown = set(raw) - {field.name for field in fields(Config) if field.name != "root"}
36
+ if unknown:
37
+ raise ValueError(f"Unknown configuration keys: {sorted(unknown)}")
38
+ for name in ("commit_check", "merge_check", "integration_check", "deploy_command", "preflight_command"):
39
+ value = raw.get(name, [])
40
+ if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
41
+ raise ValueError(f"{name} must be an argument array")
42
+ if name in ("commit_check", "deploy_command") and not value:
43
+ raise ValueError(f"{name} is required")
44
+ raw[name] = tuple(value)
45
+ for name in ("check_timeout", "deploy_timeout", "merge_timeout", "interval"):
46
+ if name in raw and (type(raw[name]) is not int or raw[name] <= 0):
47
+ raise ValueError(f"{name} must be a positive integer")
48
+ for name in ("main_ref", "remote", "deployed_ref", "deploy_tag_prefix"):
49
+ if name in raw and (not isinstance(raw[name], str) or not raw[name] or raw[name].startswith("-")):
50
+ raise ValueError(f"Invalid {name}")
51
+ return Config(root=root, **raw)
coloph_sync/engine.py ADDED
@@ -0,0 +1,345 @@
1
+ """Local integration and deployment engine.
2
+
3
+ Project commands own checks and deployment internals.
4
+ """
5
+
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import time
10
+ import uuid
11
+ from datetime import UTC, datetime
12
+
13
+ from .config import Config
14
+ from .git import Git, run_checked
15
+ from .state import CommitState, read_state
16
+ from .storage import lock, read_json, write_json
17
+
18
+
19
+ def now():
20
+ return datetime.now(UTC).isoformat()
21
+
22
+
23
+ class Engine:
24
+ def __init__(self, config: Config):
25
+ self.config = config
26
+ self.git = Git(config.root)
27
+ self.directory = self.git.common_dir()
28
+ self.report_path = self.directory / "sync-report.json"
29
+ self.delivery_path = self.directory / "coloph-sync-delivery.json"
30
+ self.stop_path = self.directory / "coloph-sync-stop.json"
31
+ self.owner_path = self.directory / "coloph-sync-owner.json"
32
+ self.report = read_json(self.report_path)
33
+ self.run_id = f"sync-{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}"
34
+ self.draining = False
35
+ self.rollback = False
36
+ self.manual_sha = None
37
+ self.mode = "run"
38
+ self.branch = None
39
+
40
+ def save(self, phase=None):
41
+ self.report.update(timestamp=now(), sync_run_id=self.run_id)
42
+ if phase:
43
+ self.report.update(current_phase=phase, phase_started_at=now())
44
+ self.report.pop("error", None)
45
+ write_json(self.report_path, self.report)
46
+
47
+ def deployed(self):
48
+ delivery = read_json(self.delivery_path)
49
+ if delivery.get("attempt", {}).get("status") == "completed":
50
+ return delivery["attempt"]["sha"]
51
+ return delivery.get("deployed_sha") or self.git.resolve(f"refs/tags/{self.config.deployed_ref}")
52
+
53
+ def command(self, command, context, *, sha=None, attempt=None, timeout=None):
54
+ env = {
55
+ **os.environ,
56
+ "COLOPH_SYNC_CONTEXT": context,
57
+ "COLOPH_SYNC_RUN_ID": self.run_id,
58
+ "COLOPH_SYNC_COMMIT": sha or self.git.out("rev-parse", "HEAD"),
59
+ "COLOPH_SYNC_ATTEMPT_ID": attempt or "",
60
+ "COLOPH_SYNC_DEPLOYED_COMMIT": self.deployed() or "",
61
+ "COLOPH_SYNC_ROLLBACK": "1" if self.rollback else "0",
62
+ "COLOPH_SYNC_MODE": self.mode,
63
+ }
64
+ log = self.directory / f"coloph-sync-{self.run_id}.log"
65
+ with log.open("a") as stream:
66
+ stream.write(f"\n[{now()}] {context}\n")
67
+ stream.flush()
68
+
69
+ def output(line):
70
+ stream.write(line)
71
+ stream.flush()
72
+ print(line, end="", flush=True)
73
+
74
+ result = run_checked(
75
+ command, cwd=self.config.root, env=env, timeout=timeout or self.config.check_timeout, output=output
76
+ )
77
+ result.check_returncode()
78
+
79
+ def barrier(self, branch, deployed):
80
+ # Original pending_deploy_barrier algorithm, using typed body states.
81
+ for sha in self.git.commits(f"{deployed}..{branch}" if deployed else branch):
82
+ if read_state(self.git.message(sha)) != CommitState.DEPLOY_BARRIER:
83
+ continue
84
+ parent = self.git.out("rev-parse", f"{sha}^")
85
+ if deployed and self.git.ancestor(parent, deployed):
86
+ continue
87
+ return sha, parent
88
+ return None
89
+
90
+ def metadata_errors(self, range_spec):
91
+ errors = []
92
+ for sha in self.git.commits(range_spec):
93
+ try:
94
+ state = read_state(self.git.message(sha))
95
+ if state is None:
96
+ errors.append(f"{sha[:10]} missing Sync-State")
97
+ if state == CommitState.DEPLOY_BARRIER:
98
+ parents = self.git.out("rev-list", "--parents", "-n", "1", sha).split()[1:]
99
+ if len(parents) != 1 or self.git.out("rev-parse", f"{sha}^{{tree}}") != self.git.out(
100
+ "rev-parse", f"{parents[0]}^{{tree}}"
101
+ ):
102
+ errors.append(f"{sha[:10]} deployment barrier must be empty with one parent")
103
+ except ValueError as exc:
104
+ errors.append(f"{sha[:10]} {exc}")
105
+ return errors
106
+
107
+ def merge_in(self):
108
+ self.save("merge")
109
+ entries = self.report.setdefault("branches", {})
110
+ for branch in sorted(self.git.worktrees()):
111
+ if branch == self.config.main_ref:
112
+ continue
113
+ if self.branch is not None and branch != self.branch:
114
+ continue
115
+ target = self.git.out("rev-parse", "HEAD")
116
+ tip = self.git.resolve(branch)
117
+ if tip is None:
118
+ entries[branch] = {
119
+ **entries.get(branch, {}),
120
+ "last_sync_run_id": self.run_id,
121
+ "last_merge_attempt": {"at": now(), "outcome": "skipped", "reason": "branch disappeared"},
122
+ }
123
+ self.save()
124
+ continue
125
+ previous = entries.get(branch, {})
126
+ entry = {
127
+ **previous,
128
+ "branch_sha": tip,
129
+ "merge_target_sha": target,
130
+ "last_seen_at": now(),
131
+ "last_sync_run_id": self.run_id,
132
+ }
133
+ entries[branch] = entry
134
+ reason = None
135
+ outcome = "skipped"
136
+ if self.git.ancestor(tip, target):
137
+ outcome = "already_merged"
138
+ elif (
139
+ previous.get("branch_sha") == tip
140
+ and previous.get("merge_status") == "not_merged"
141
+ and (
142
+ previous.get("reason_code") == "metadata"
143
+ or (previous.get("reason_code") == "conflict" and previous.get("merge_target_sha") == target)
144
+ )
145
+ ):
146
+ reason = previous["merge_reason"]
147
+ outcome = "unchanged"
148
+ else:
149
+ try:
150
+ state = read_state(self.git.message(tip))
151
+ if state in (CommitState.WIP, CommitState.FAILED, CommitState.DONT_MERGE):
152
+ reason = state.value
153
+ entry["reason_code"] = "state"
154
+ else:
155
+ barrier = self.barrier(tip, self.deployed())
156
+ merge_sha = barrier[1] if barrier else tip
157
+ base = self.git.out("merge-base", target, merge_sha)
158
+ errors = self.metadata_errors(f"{base}..{merge_sha}")
159
+ if errors:
160
+ reason = "metadata guard failed: " + "; ".join(errors)
161
+ entry["reason_code"] = "metadata"
162
+ else:
163
+ if not self.git.ancestor(merge_sha, target):
164
+ environment = {**os.environ, "COLOPH_SYNC_AUTOMATIC_MERGE": "1"}
165
+ result = run_checked(
166
+ ["git", "merge", "--no-edit", merge_sha],
167
+ cwd=self.config.root,
168
+ env=environment,
169
+ timeout=self.config.merge_timeout,
170
+ output=lambda line: print(line, end="", flush=True),
171
+ )
172
+ result.check_returncode()
173
+ if read_state(self.git.message("HEAD")) not in (
174
+ CommitState.PASSED,
175
+ CommitState.DEPLOY_BARRIER,
176
+ ):
177
+ raise RuntimeError(
178
+ "The merge did not produce a passed commit; install the commit hook and repair before resuming"
179
+ )
180
+ if barrier:
181
+ reason = f"merged up to {merge_sha[:10]} due to deployment barrier {barrier[0][:10]}; waiting for deployment"
182
+ outcome = "partial_merged"
183
+ entry["reason_code"] = "barrier"
184
+ else:
185
+ outcome = "merged"
186
+ except ValueError as exc:
187
+ reason = f"metadata guard failed: {exc}"
188
+ entry["reason_code"] = "metadata"
189
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
190
+ if self.git.resolve("MERGE_HEAD"):
191
+ self.git.out("merge", "--abort")
192
+ reason = "merge timed out" if isinstance(exc, subprocess.TimeoutExpired) else "merge failed"
193
+ entry["reason_code"] = "timeout" if isinstance(exc, subprocess.TimeoutExpired) else "conflict"
194
+ outcome = "failed"
195
+ entry.update(
196
+ merge_status="not_merged" if reason else "merged",
197
+ merge_reason=reason,
198
+ last_merge_attempt={"at": now(), "outcome": outcome},
199
+ )
200
+ if not reason:
201
+ entry.pop("reason_code", None)
202
+ entry["merged_at"] = now()
203
+ self.save()
204
+
205
+ def remote_ref(self, ref):
206
+ output = self.git.out("ls-remote", self.config.remote, ref)
207
+ for line in output.splitlines():
208
+ sha, name = line.split()
209
+ if name == ref:
210
+ return sha
211
+ return None
212
+
213
+ def publish(self, delivery):
214
+ attempt = delivery["attempt"]
215
+ sha = attempt["sha"]
216
+ immutable = f"refs/tags/{self.config.deploy_tag_prefix}/{attempt['id']}"
217
+ floating = f"refs/tags/{self.config.deployed_ref}"
218
+ found = self.remote_ref(immutable)
219
+ if found not in (None, sha):
220
+ raise RuntimeError(f"Deployment tag conflict: {immutable}")
221
+ self.git.out("update-ref", immutable, sha)
222
+ if found is None:
223
+ self.git.out("push", self.config.remote, f"{immutable}:{immutable}", timeout=120)
224
+ previous = attempt["previous_remote"]
225
+ found = self.remote_ref(floating)
226
+ if found != sha:
227
+ if found != previous:
228
+ raise RuntimeError("Deployed ref changed concurrently; reconcile before resuming")
229
+ self.git.out(
230
+ "push",
231
+ f"--force-with-lease={floating}:{previous or ''}",
232
+ self.config.remote,
233
+ f"{sha}:{floating}",
234
+ timeout=120,
235
+ )
236
+ self.git.out("update-ref", floating, sha)
237
+ attempt["status"] = "published"
238
+ delivery["deployed_sha"] = sha
239
+ write_json(self.delivery_path, delivery)
240
+ self.report["deploy_sha"] = sha
241
+ self.save("done")
242
+
243
+ def deploy(self, sha):
244
+ delivery = read_json(self.delivery_path)
245
+ previous = delivery.get("attempt", {})
246
+ if previous and previous["status"] != "published":
247
+ if previous["sha"] != sha:
248
+ raise RuntimeError(
249
+ f"Resolve deploy attempt {previous['id']} for {previous['sha']} before deploying another commit"
250
+ )
251
+ attempt = previous
252
+ self.rollback = attempt.get("rollback", False)
253
+ else:
254
+ deployed = self.deployed()
255
+ if deployed and not self.rollback and not self.git.ancestor(deployed, sha):
256
+ raise RuntimeError("Automatic rollback is not supported; reconcile deployment state explicitly")
257
+ if deployed == sha:
258
+ self.report["deploy_sha"] = sha
259
+ self.save("done")
260
+ return
261
+ attempt = {
262
+ "id": f"{self.run_id}-{uuid.uuid4().hex[:8]}",
263
+ "sha": sha,
264
+ "status": "running",
265
+ "previous_remote": self.remote_ref(f"refs/tags/{self.config.deployed_ref}"),
266
+ "rollback": self.rollback,
267
+ }
268
+ delivery["attempt"] = attempt
269
+ write_json(self.delivery_path, delivery)
270
+ if attempt["status"] != "completed":
271
+ self.save("deploy")
272
+ self.command(
273
+ self.config.deploy_command, "deploy", sha=sha, attempt=attempt["id"], timeout=self.config.deploy_timeout
274
+ )
275
+ attempt.update(status="completed", completed_at=now())
276
+ write_json(self.delivery_path, delivery)
277
+ self.save("publish")
278
+ self.publish(delivery)
279
+
280
+ def cycle(self, *, deploy_only=False, push_deploy_only=False):
281
+ if self.git.out("branch", "--show-current") != self.config.main_ref:
282
+ raise RuntimeError(f"Run the coordinator on {self.config.main_ref}")
283
+ if self.git.out("status", "--porcelain"):
284
+ raise RuntimeError("The coordinator checkout must be clean")
285
+ if read_state(self.git.message("HEAD")) not in (CommitState.PASSED, CommitState.DEPLOY_BARRIER):
286
+ raise RuntimeError("The integration branch must have a checked commit before running")
287
+ pending = read_json(self.delivery_path).get("attempt", {})
288
+ if pending and pending["status"] != "published":
289
+ self.deploy(pending["sha"])
290
+ return
291
+ if self.config.preflight_command:
292
+ self.save("preflight")
293
+ self.command(self.config.preflight_command, "preflight")
294
+ if not deploy_only:
295
+ if not push_deploy_only:
296
+ self.merge_in()
297
+ self.save("verify")
298
+ self.report["checks_status"] = "running"
299
+ self.save()
300
+ self.command(self.config.integration_check or self.config.commit_check, "integration")
301
+ self.report["checks_status"] = "passed"
302
+ self.save()
303
+ sha = self.git.resolve(self.manual_sha) if self.manual_sha else self.git.out("rev-parse", "HEAD")
304
+ if not sha:
305
+ raise ValueError("Deployment target does not exist")
306
+ if not deploy_only:
307
+ self.save("push")
308
+ self.git.out("push", self.config.remote, self.config.main_ref)
309
+ else:
310
+ self.git.out("fetch", self.config.remote, self.config.main_ref)
311
+ if not self.git.ancestor(sha, f"{self.config.remote}/{self.config.main_ref}"):
312
+ raise RuntimeError("Push the deployment target before a manual deploy")
313
+ self.deploy(sha)
314
+
315
+ def run(self, *, once=False, deploy_only=False, push_deploy_only=False):
316
+ with lock(self.directory / "sync-test-push.lock"):
317
+ owner = {"pid": os.getpid(), "id": self.run_id}
318
+ write_json(self.owner_path, owner)
319
+ old_signal = signal.signal(signal.SIGTERM, lambda *_: setattr(self, "draining", True))
320
+ try:
321
+ while True:
322
+ try:
323
+ self.cycle(deploy_only=deploy_only, push_deploy_only=push_deploy_only)
324
+ except (RuntimeError, ValueError, OSError, subprocess.SubprocessError, KeyboardInterrupt) as exc:
325
+ phase = self.report.get("current_phase", "startup")
326
+ if phase == "verify":
327
+ self.report["checks_status"] = "failed"
328
+ self.report.update(
329
+ current_phase="error", error={"phase": phase, "at": now(), "message": str(exc)}
330
+ )
331
+ self.save()
332
+ raise
333
+ if once or deploy_only or push_deploy_only:
334
+ return
335
+ deadline = time.monotonic() + self.config.interval
336
+ while time.monotonic() < deadline:
337
+ if self.draining or read_json(self.stop_path).get("id") == owner["id"]:
338
+ return
339
+ time.sleep(min(0.2, max(0, deadline - time.monotonic())))
340
+ if self.draining or read_json(self.stop_path).get("id") == owner["id"]:
341
+ return
342
+ self.run_id = f"sync-{uuid.uuid4().hex}"
343
+ finally:
344
+ signal.signal(signal.SIGTERM, old_signal)
345
+ self.owner_path.unlink(missing_ok=True)