repository-rename: source-synced may advance the Forge head past the baseline
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 27s

The runbook pushes a source-synchronization revision before source-synced, but
the phase required the head to still equal the preflight baseline. Anchor later
phases on the head recorded by source-synced; require the clone to match Forge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 101414@bnt-lap001
Assistant-Session: 60b5a221-d435-42b9-b778-983c90c7eed6
This commit is contained in:
tegwick 2026-09-30 18:08:14 +02:00
parent 7d04c05484
commit cfb038d76b
2 changed files with 90 additions and 13 deletions

View file

@ -1052,12 +1052,28 @@ async def list_operations(
return list(result.scalars().all())
def _expected_head(operation: RepositoryRenameOperation) -> str:
"""Head the Forge default branch must show at this point in the journal.
The preflight baseline anchors every phase up to ``statehub-rebound``.
``source-synced`` legitimately advances the head (the source-synchronization
revision and fix-consistency writeback are pushed from the fresh clone), so
once that phase is journalled its recorded Forge head is the anchor.
"""
journal, _ = _telemetry(operation)
synced = (journal.get("phases") or {}).get("source-synced") or {}
head = (synced.get("forge") or {}).get("head_commit")
return head or operation.expected_source_commit
def _assert_snapshot(
operation: RepositoryRenameOperation,
snapshot: ForgeRepositorySnapshot,
*,
expected_name: str | None = None,
expected_head: str | None = None,
) -> None:
expected_head = expected_head or _expected_head(operation)
if snapshot.repository_id != operation.expected_forge_repository_id:
raise RenamePreconditionFailed(
"Forge repository ID changed",
@ -1068,11 +1084,11 @@ def _assert_snapshot(
)
if snapshot.default_branch != operation.expected_default_branch:
raise RenamePreconditionFailed("Forge default branch changed")
if snapshot.head_commit != operation.expected_source_commit:
if snapshot.head_commit != expected_head:
raise RenamePreconditionFailed(
"Forge default-branch head moved",
details={
"expected": operation.expected_source_commit,
"expected": expected_head,
"actual": snapshot.head_commit,
},
)
@ -1232,7 +1248,7 @@ async def verify_operation(
if forge is not None:
add("forge_coordinate", forge.name == expected_name, expected_name, forge.name)
add("forge_repository_id", forge.repository_id == operation.expected_forge_repository_id, operation.expected_forge_repository_id, forge.repository_id)
add("source_commit", forge.head_commit == operation.expected_source_commit, operation.expected_source_commit, forge.head_commit)
add("source_commit", forge.head_commit == _expected_head(operation), _expected_head(operation), forge.head_commit)
add("default_branch", forge.default_branch == operation.expected_default_branch, operation.expected_default_branch, forge.default_branch)
add("projection_readable", forge.projection_readable and forge.projection_source_present, True, forge.projection_readable and forge.projection_source_present)
expected_canonical = operation.old_slug if operation.phase in {"preflighted", "forge-renamed", "rolled-back"} else operation.new_slug
@ -1503,19 +1519,29 @@ async def apply_phase(
},
)
clone_head = body.evidence.get("head_commit")
if clone_head != operation.expected_source_commit:
raise RenamePreconditionFailed(
"Fresh clone head does not match the rename baseline",
details={
"expected": operation.expected_source_commit,
"actual": clone_head,
},
)
forge = await _forge_at(gateway, operation, operation.new_slug)
if forge is None:
raise RenamePreconditionFailed("Renamed Forge repository is absent")
_assert_snapshot(operation, forge, expected_name=operation.new_slug)
evidence = {"forge": forge.as_dict(), "operator_evidence": body.evidence}
if clone_head != forge.head_commit:
raise RenamePreconditionFailed(
"Fresh clone head does not match the Forge default-branch head",
details={
"expected": forge.head_commit,
"actual": clone_head,
"baseline": operation.expected_source_commit,
},
)
# The head may have advanced past the baseline through the
# source-synchronization revision; it becomes the new anchor.
_assert_snapshot(
operation, forge, expected_name=operation.new_slug, expected_head=clone_head
)
evidence = {
"forge": forge.as_dict(),
"baseline_commit": operation.expected_source_commit,
"head_advanced": forge.head_commit != operation.expected_source_commit,
"operator_evidence": body.evidence,
}
elif requested_phase == "consumers-verified":
if not body.checks or not all(body.checks.values()):
raise RenamePreconditionFailed(

View file

@ -735,6 +735,57 @@ async def test_redirected_forge_and_wrong_fresh_clone_identity_fail_closed_then_
assert replay["evidence"]["telemetry"]["retries"] == 1
@pytest.mark.asyncio
async def test_source_sync_may_advance_forge_head_past_the_baseline(client, rename_setup):
repo, forge = rename_setup
preflight = await _preflight(client, repo["id"])
operation, confirmation = await _operation(client, repo["id"], preflight)
await _phase(client, repo["id"], operation["id"], "forge-renamed", "preflighted", confirmation)
await _phase(client, repo["id"], operation["id"], "statehub-rebound", "forge-renamed", confirmation)
# The source-synchronization revision is pushed from the fresh clone.
advanced = "b" * 40
forge.head = advanced
stale = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/phases/source-synced",
json={
"expected_phase": "statehub-rebound",
"confirmation": confirmation,
"evidence": {"fresh_clone": True, "forge_repository_id": 417, "head_commit": "a" * 40},
},
)
assert stale.status_code == 412
synced = await _phase(
client,
repo["id"],
operation["id"],
"source-synced",
"statehub-rebound",
confirmation,
evidence={"fresh_clone": True, "forge_repository_id": 417, "head_commit": advanced},
)
assert synced["phase"] == "source-synced"
recorded = synced["evidence"]["phases"]["source-synced"]
assert recorded["head_advanced"] is True
assert recorded["baseline_commit"] == "a" * 40
# Later phases anchor on the recorded head, so drift beyond it still fails.
verify = await client.get(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/verify"
)
assert verify.status_code == 200, verify.text
assert all(
check["ok"] for check in verify.json()["checks"] if check["name"] == "source_commit"
)
forge.head = "c" * 40
drifted = await client.get(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/verify"
)
assert any(
not check["ok"] for check in drifted.json()["checks"] if check["name"] == "source_commit"
)
@pytest.mark.asyncio
async def test_rollback_recovers_when_old_statehub_slug_temporarily_unavailable(
client, test_engine, rename_setup