fix: synchronize node main directly without capture CI [skip ci]
This commit is contained in:
2 files changed
+160
-32
No files matched your search
+27
-32
@@ -1,64 +1,59 @@
|
|||||||
# actions/sync/repository.py
|
"""Commit node configuration and synchronize main directly with its remote."""
|
||||||
"""Port of sync/repository — capture-up commit + rebase onto origin/main + force-push."""
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from engine.descriptor import Meta, Arg
|
from engine.descriptor import Meta, Arg
|
||||||
from engine.identity import read_env
|
from engine.identity import read_env
|
||||||
|
|
||||||
METADATA = Meta(summary="Capture-up: commit node changes, rebase onto origin/main, force-push sync/<node>.",
|
METADATA = Meta(summary="Commit node changes, rebase onto origin/main, and push main.",
|
||||||
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
|
args=[Arg("telegram_bot_url", "Optional notification URL", required=False)])
|
||||||
TITLE = "Annual Remote Sync"
|
TITLE = "Repository Sync"
|
||||||
|
|
||||||
|
|
||||||
def run(ctx, args):
|
def run(ctx, args):
|
||||||
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
|
log, node = ctx.log, ctx.node.name
|
||||||
|
url = args.telegram_bot_url
|
||||||
repo, commons = ctx.paths.repo_root, ctx.paths.commons
|
repo, commons = ctx.paths.repo_root, ctx.paths.commons
|
||||||
ne = read_env(os.path.join(repo, "node.env")) # per-node bot identity (generic defaults)
|
ne = read_env(os.path.join(repo, "node.env"))
|
||||||
author = ne.get("APEX_GIT_AUTHOR_NAME", "apex [bot]")
|
author = ne.get("APEX_GIT_AUTHOR_NAME", "apex [bot]")
|
||||||
email = ne.get("APEX_GIT_AUTHOR_EMAIL", "apex@localhost")
|
email = ne.get("APEX_GIT_AUTHOR_EMAIL", "apex@localhost")
|
||||||
env = dict(os.environ,
|
env = dict(os.environ, GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email,
|
||||||
GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email,
|
|
||||||
GIT_COMMITTER_NAME=author, GIT_COMMITTER_EMAIL=email)
|
GIT_COMMITTER_NAME=author, GIT_COMMITTER_EMAIL=email)
|
||||||
|
|
||||||
def git(*a, check=True):
|
def git(*a, check=True, capture=False):
|
||||||
return ctx.sys.run(["git", "-C", repo, *a], check=check, env=env)
|
return ctx.sys.run(["git", "-C", repo, *a], check=check, capture=capture, env=env)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
branch = git("symbolic-ref", "--quiet", "--short", "HEAD", capture=True).stdout.strip()
|
||||||
|
if branch != "main":
|
||||||
|
raise RuntimeError("Repository sync requires main; switch branches explicitly first.")
|
||||||
cp = ctx.sys.run(["git", "-C", commons, "status", "--porcelain"], capture=True)
|
cp = ctx.sys.run(["git", "-C", commons, "status", "--porcelain"], capture=True)
|
||||||
if (cp.stdout or "").strip():
|
if (cp.stdout or "").strip():
|
||||||
log.error(f"Commons submodule at {commons} has uncommitted changes. Aborting.")
|
raise RuntimeError(f"Commons submodule at {commons} has uncommitted changes.")
|
||||||
raise SystemExit(1)
|
|
||||||
if not os.path.isdir(ctx.paths.compositions):
|
if not os.path.isdir(ctx.paths.compositions):
|
||||||
# Without this, `git add -A` would stage a deleted node tree and an
|
raise RuntimeError(f"Directory '{ctx.paths.compositions}' does not exist.")
|
||||||
# unattended cron run would commit + force-push the deletion.
|
expected = git("rev-parse", "HEAD:commons", capture=True).stdout.strip()
|
||||||
log.error(f"Directory '{ctx.paths.compositions}' does not exist. Cannot sync.")
|
actual = ctx.sys.run(["git", "-C", commons, "rev-parse", "HEAD"], capture=True).stdout.strip()
|
||||||
raise SystemExit(1)
|
if actual != expected:
|
||||||
branch = f"sync/{node}"
|
raise RuntimeError("Commons checkout differs from the committed pin; update the submodule before retrying.")
|
||||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
if ctx.sys.ok(["git", "-C", repo, "rev-parse", "--verify", branch]):
|
|
||||||
git("checkout", branch)
|
|
||||||
else:
|
|
||||||
git("checkout", "-b", branch)
|
|
||||||
git("add", "-A")
|
git("add", "-A")
|
||||||
git("submodule", "update", "--init", "--recursive")
|
|
||||||
git("add", "commons")
|
|
||||||
if git("diff", "--cached", "--quiet", check=False).returncode != 0:
|
if git("diff", "--cached", "--quiet", check=False).returncode != 0:
|
||||||
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
git("commit", "-m", f"[{node}] Auto-commit: {ts}")
|
git("commit", "-m", f"[{node}] Auto-commit: {ts}")
|
||||||
else:
|
else:
|
||||||
log.info("No changes to commit.")
|
log.info("No changes to commit.")
|
||||||
git("fetch", "origin", "main")
|
git("fetch", "origin", "main")
|
||||||
if git("rebase", "origin/main", check=False).returncode != 0:
|
if git("rebase", "origin/main", check=False).returncode != 0:
|
||||||
log.error("Rebase conflict — aborting rebase; manual intervention required.")
|
|
||||||
git("rebase", "--abort", check=False)
|
git("rebase", "--abort", check=False)
|
||||||
ctx.notify.error(TITLE, url, "Rebase conflict. Manual intervention required.")
|
raise RuntimeError("Rebase conflict; local commits retained for manual resolution.")
|
||||||
raise SystemExit(1)
|
|
||||||
git("submodule", "update", "--init", "--recursive")
|
git("submodule", "update", "--init", "--recursive")
|
||||||
git("push", "--force-with-lease", "origin", branch)
|
git("push", "origin", "HEAD:refs/heads/main")
|
||||||
log.success(f"Pushed {branch}.")
|
log.success("Synchronized main.")
|
||||||
if not ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed."):
|
if url and not ctx.notify.success(TITLE, url, "Sync to main completed."):
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"{type(e).__name__}: {e}")
|
log.error(f"{type(e).__name__}: {e}")
|
||||||
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
|
if url:
|
||||||
|
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
|
||||||
raise SystemExit(1) from e
|
raise SystemExit(1) from e
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
from actions.sync import repository
|
||||||
|
from engine.lib.sys import Sys
|
||||||
|
|
||||||
|
|
||||||
|
class RepositorySyncTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.remote = self.root / "remote.git"
|
||||||
|
self.repo = self.root / "node"
|
||||||
|
self.env = dict(os.environ, GIT_AUTHOR_NAME="Test", GIT_AUTHOR_EMAIL="test@example.invalid",
|
||||||
|
GIT_COMMITTER_NAME="Test", GIT_COMMITTER_EMAIL="test@example.invalid",
|
||||||
|
GIT_ALLOW_PROTOCOL="file")
|
||||||
|
self.git("init", "--bare", str(self.remote))
|
||||||
|
self.git("clone", str(self.remote), str(self.repo))
|
||||||
|
self.git("checkout", "-b", "main", cwd=self.repo)
|
||||||
|
(self.repo / "compositions").mkdir()
|
||||||
|
(self.repo / "compositions/service").write_text("base\n")
|
||||||
|
self.commons_source = self.root / "shared"
|
||||||
|
self.git("init", str(self.commons_source))
|
||||||
|
(self.commons_source / "file").write_text("shared\n")
|
||||||
|
self.git("add", ".", cwd=self.commons_source)
|
||||||
|
self.git("commit", "-m", "shared", cwd=self.commons_source)
|
||||||
|
self.git("submodule", "add", str(self.commons_source), "commons", cwd=self.repo)
|
||||||
|
self.git("add", ".", cwd=self.repo)
|
||||||
|
self.git("commit", "-m", "base", cwd=self.repo)
|
||||||
|
self.git("push", "origin", "main", cwd=self.repo)
|
||||||
|
self.log = Mock()
|
||||||
|
class Commands(Sys):
|
||||||
|
def run(inner, cmd, **kw):
|
||||||
|
kw["env"] = dict(self.env, **kw.get("env", {}))
|
||||||
|
kw["env"]["GIT_ALLOW_PROTOCOL"] = "file"
|
||||||
|
return super().run(cmd, **kw)
|
||||||
|
self.ctx = SimpleNamespace(log=self.log, sys=Commands(self.log), notify=Mock(),
|
||||||
|
node=SimpleNamespace(name="node"),
|
||||||
|
paths=SimpleNamespace(repo_root=str(self.repo), commons=str(self.repo / "commons"),
|
||||||
|
compositions=str(self.repo / "compositions")))
|
||||||
|
self.args = SimpleNamespace(telegram_bot_url=None)
|
||||||
|
|
||||||
|
def git(self, *args, cwd=None):
|
||||||
|
return subprocess.check_output(["git", *args], cwd=cwd, env=self.env, stderr=subprocess.PIPE, text=True).strip()
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
repository.run(self.ctx, self.args)
|
||||||
|
|
||||||
|
def test_local_change_publishes_main_without_sync_branch_or_notification(self):
|
||||||
|
(self.repo / "compositions/service").write_text("changed\n")
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(self.git("--git-dir=" + str(self.remote), "show", "main:compositions/service"), "changed")
|
||||||
|
self.assertEqual(self.git("--git-dir=" + str(self.remote), "for-each-ref", "--format=%(refname)", "refs/heads"), "refs/heads/main")
|
||||||
|
self.ctx.notify.success.assert_not_called()
|
||||||
|
self.ctx.notify.error.assert_not_called()
|
||||||
|
|
||||||
|
def test_noop_keeps_head(self):
|
||||||
|
before = self.git("rev-parse", "HEAD", cwd=self.repo)
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(before, self.git("rev-parse", "HEAD", cwd=self.repo))
|
||||||
|
|
||||||
|
def test_other_branch_does_not_stage_changes(self):
|
||||||
|
self.git("checkout", "-b", "work", cwd=self.repo)
|
||||||
|
(self.repo / "compositions/service").write_text("changed\n")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(self.git("diff", "--cached", cwd=self.repo), "")
|
||||||
|
|
||||||
|
def test_dirty_commons_blocks_parent_commit(self):
|
||||||
|
before = self.git("rev-parse", "HEAD", cwd=self.repo)
|
||||||
|
(self.repo / "commons/file").write_text("dirty\n")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(before, self.git("rev-parse", "HEAD", cwd=self.repo))
|
||||||
|
|
||||||
|
def test_stale_submodule_after_failed_update_cannot_rollback_pin(self):
|
||||||
|
old = self.git("rev-parse", "HEAD", cwd=self.repo / "commons")
|
||||||
|
(self.commons_source / "file").write_text("new shared\n")
|
||||||
|
self.git("add", ".", cwd=self.commons_source)
|
||||||
|
self.git("commit", "-m", "new shared", cwd=self.commons_source)
|
||||||
|
new = self.git("rev-parse", "HEAD", cwd=self.commons_source)
|
||||||
|
self.git("-C", str(self.repo / "commons"), "fetch", "origin")
|
||||||
|
self.git("update-index", "--cacheinfo", "160000," + new + ",commons", cwd=self.repo)
|
||||||
|
self.git("commit", "-m", "advance pin", cwd=self.repo)
|
||||||
|
before = self.git("rev-parse", "HEAD", cwd=self.repo)
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(before, self.git("rev-parse", "HEAD", cwd=self.repo))
|
||||||
|
self.assertEqual(old, self.git("rev-parse", "HEAD", cwd=self.repo / "commons"))
|
||||||
|
self.git("submodule", "update", "--init", cwd=self.repo)
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(new, self.git("--git-dir=" + str(self.remote), "rev-parse", "main:commons"))
|
||||||
|
|
||||||
|
def test_concurrent_main_advance_rejects_push_without_overwriting(self):
|
||||||
|
original = self.ctx.sys.run
|
||||||
|
other = self.root / "other"
|
||||||
|
self.git("clone", "--branch", "main", str(self.remote), str(other))
|
||||||
|
def race(cmd, **kw):
|
||||||
|
if "push" in cmd:
|
||||||
|
(other / "compositions/service").write_text("concurrent\n")
|
||||||
|
self.git("add", ".", cwd=other)
|
||||||
|
self.git("commit", "-m", "concurrent", cwd=other)
|
||||||
|
self.git("push", "origin", "main", cwd=other)
|
||||||
|
return original(cmd, **kw)
|
||||||
|
self.ctx.sys.run = race
|
||||||
|
(self.repo / "compositions/service").write_text("local\n")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual("concurrent", self.git("--git-dir=" + str(self.remote), "show", "main:compositions/service"))
|
||||||
|
self.assertEqual("local", self.git("show", "HEAD:compositions/service", cwd=self.repo))
|
||||||
|
|
||||||
|
def test_conflict_preserves_local_commit_and_remote(self):
|
||||||
|
other = self.root / "other"
|
||||||
|
self.git("clone", "--branch", "main", str(self.remote), str(other))
|
||||||
|
(other / "compositions/service").write_text("remote\n")
|
||||||
|
self.git("add", ".", cwd=other)
|
||||||
|
self.git("commit", "-m", "remote", cwd=other)
|
||||||
|
self.git("push", "origin", "main", cwd=other)
|
||||||
|
(self.repo / "compositions/service").write_text("local\n")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(self.git("show", "HEAD:compositions/service", cwd=self.repo), "local")
|
||||||
|
self.assertEqual(self.git("--git-dir=" + str(self.remote), "show", "main:compositions/service"), "remote")
|
||||||
|
self.assertFalse((self.repo / ".git/rebase-merge").exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user