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.
- coloph_sync/__init__.py +1 -0
- coloph_sync/cli.py +261 -0
- coloph_sync/config.py +51 -0
- coloph_sync/engine.py +345 -0
- coloph_sync/git.py +86 -0
- coloph_sync/hooks.py +104 -0
- coloph_sync/skills/contributor/SKILL.md +24 -0
- coloph_sync/skills/finish/SKILL.md +24 -0
- coloph_sync/skills/operator/SKILL.md +17 -0
- coloph_sync/state.py +29 -0
- coloph_sync/storage.py +47 -0
- coloph_sync-0.3.1.dist-info/METADATA +134 -0
- coloph_sync-0.3.1.dist-info/RECORD +16 -0
- coloph_sync-0.3.1.dist-info/WHEEL +4 -0
- coloph_sync-0.3.1.dist-info/entry_points.txt +2 -0
- coloph_sync-0.3.1.dist-info/licenses/LICENSE +674 -0
coloph_sync/__init__.py
ADDED
|
@@ -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)
|