From 817f7f23ac2cfdbc3f85293bcabee0c57d8bfa7d Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 16 Jul 2026 04:30:18 +0200 Subject: [PATCH] fix(release): per-clone committer identity; signing opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fresh release clone in the orchestrator container has no git identity, so the release commit refused with 'Author identity unknown' — and the unconditional -S would have failed next on the keyless container. commit_and_push now sets a configurable bot identity on the clone and signs only when ROBOCO_RELEASE_SIGN_COMMITS is armed with a mounted key. --- roboco/config.py | 15 +++++++++++++++ roboco/services/release_executor.py | 9 ++++++++- tests/unit/services/test_release_executor.py | 4 +++- .../test_release_executor_commit_fail_closed.py | 15 +++++++++------ 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/roboco/config.py b/roboco/config.py index 5b2710b7..8c5433a3 100644 --- a/roboco/config.py +++ b/roboco/config.py @@ -861,6 +861,21 @@ class Settings(BaseSettings): "Even when on it only PROPOSES — the CEO approves before any publish." ), ) + release_git_name: str = Field( + default="RoboCo Release Manager", + description="Committer identity for the executor's release commit.", + ) + release_git_email: str = Field( + default="release-manager@roboco.local", + description="Committer email for the executor's release commit.", + ) + release_sign_commits: bool = Field( + default=False, + description=( + "Sign the release commit (-S). Off by default: the orchestrator " + "container carries no signing key; arm only with a mounted key." + ), + ) release_min_commits: int = Field( default=8, ge=1, diff --git a/roboco/services/release_executor.py b/roboco/services/release_executor.py index f9b54f22..ccf59354 100644 --- a/roboco/services/release_executor.py +++ b/roboco/services/release_executor.py @@ -414,12 +414,19 @@ class _GitReleaseOps: return conclusion == "success", detail async def commit_and_push(self, version: str) -> str: + from roboco.config import settings + add_rc, add_out = await self._git("add", "-A") if add_rc != 0: logger.error("release git add failed", error=add_out.strip()[:300]) raise RuntimeError(f"release git add failed: {add_out.strip()[:200]}") + # The clone is fresh per execute and the container has no global git + # identity — set it per-clone or the commit refuses outright. + await self._git("config", "user.name", settings.release_git_name) + await self._git("config", "user.email", settings.release_git_email) + sign = ["-S"] if settings.release_sign_commits else [] commit_rc, commit_out = await self._git( - "commit", "-S", "-m", f"chore(release): {version}" + "commit", *sign, "-m", f"chore(release): {version}" ) if commit_rc != 0: # A failed commit (gpgsign/pre-commit reject/no-op bump) must abort diff --git a/tests/unit/services/test_release_executor.py b/tests/unit/services/test_release_executor.py index ce88ecd0..ebaacbd2 100644 --- a/tests/unit/services/test_release_executor.py +++ b/tests/unit/services/test_release_executor.py @@ -520,10 +520,12 @@ async def test_release_push_argv_uses_extraheader_not_url_token( git_prefix = ["-c", f"http.extraheader=Authorization: Basic {expected_basic}"] captured: list[list[str]] = [] - # commit_and_push issues: add -A, commit -S -m, rev-parse HEAD, push. + # commit_and_push issues: add -A, 2x identity config, commit, rev-parse, push. responses = iter( [ _DoneProc(b""), # add -A + _DoneProc(b""), # config user.name + _DoneProc(b""), # config user.email _DoneProc(b""), # commit _DoneProc(b"deadbeef\n"), # rev-parse HEAD _DoneProc(b"ok"), # push diff --git a/tests/unit/services/test_release_executor_commit_fail_closed.py b/tests/unit/services/test_release_executor_commit_fail_closed.py index 2754f177..960819f5 100644 --- a/tests/unit/services/test_release_executor_commit_fail_closed.py +++ b/tests/unit/services/test_release_executor_commit_fail_closed.py @@ -50,8 +50,9 @@ async def test_commit_failure_aborts_before_push() -> None: """A failed ``git commit`` must raise — never push the pre-bump base.""" ops = _FakeGitOps( _ctx(), - # add ok, commit FAILS (rc=1, e.g. gpgsign/pre-commit reject). - script=[(0, ""), (1, "error: gpg failed to sign the data")], + # add ok, 2x identity config ok, commit FAILS (rc=1, e.g. + # gpgsign/pre-commit reject). + script=[(0, ""), (0, ""), (0, ""), (1, "error: gpg failed to sign the data")], ) with pytest.raises(RuntimeError, match="commit"): await ops.commit_and_push("0.13.0") @@ -75,11 +76,13 @@ async def test_green_commit_then_push_returns_sha() -> None: """Happy path: add ok, commit ok, rev-parse sha, push ok → returns the sha.""" ops = _FakeGitOps( _ctx(), - script=[(0, ""), (0, ""), (0, "deadbeef\n"), (0, "ok")], + script=[(0, ""), (0, ""), (0, ""), (0, ""), (0, "deadbeef\n"), (0, "ok")], ) sha = await ops.commit_and_push("0.13.0") assert sha == "deadbeef" assert ops.calls[0][:1] == ("add",) - assert ops.calls[1][:1] == ("commit",) - assert ops.calls[2][:1] == ("rev-parse",) - assert ops.calls[3][:1] == ("push",) + assert ops.calls[1][:1] == ("config",) + assert ops.calls[2][:1] == ("config",) + assert ops.calls[3][:1] == ("commit",) + assert ops.calls[4][:1] == ("rev-parse",) + assert ops.calls[5][:1] == ("push",)