fix: synchronize node main directly without capture CI [skip ci]

This commit is contained in:
deytenit committed 2026-11-08 12:39:48 +03:00
1 parent c091cbfb80
commit 36c50290d1
2 files changed
+160 -32

No files matched your search

+27 -32
View File
@@ -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
+133
View File
@@ -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()