fix(release): per-clone committer identity; signing opt-in

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.
This commit is contained in:
Renn F
2026-07-16 04:30:18 +02:00
parent ce5e263b79
commit 817f7f23ac
4 changed files with 35 additions and 8 deletions
+15
View File
@@ -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,
+8 -1
View File
@@ -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
+3 -1
View File
@@ -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
@@ -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",)