repository-rename: source-synced may advance the Forge head past the baseline
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:
parent
7d04c05484
commit
cfb038d76b
2 changed files with 90 additions and 13 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue