fix(deploy): persist the artifact deployment record before the best-effort site stamp

Both repositories are scoped over one DbContext, so a failed stamp save left the
Site entries tracked as Modified and the deployment record's (uncaught)
SaveChangesAsync rethrew it — losing the audit row and failing a deploy in which
every site succeeded. Ordering rule now: audit row first, stamp after, inside its
own try/catch. Same in RetryForSiteAsync, where the site load is caught too so
nothing on the stamp path can reach the method's catch.

Also: the method-definitions canonicaliser matches the name property
case-insensitively (a naming-policy change at the producer would otherwise
silently turn the sort into a no-op), and ComputeCurrentArtifactHashesAsync
documents that a per-site query failure aborts the whole batch by design.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-14 13:51:34 -04:00
parent fc48db480c
commit 0287f8f64f
4 changed files with 156 additions and 43 deletions
@@ -103,6 +103,39 @@ public class ArtifactRevisionHashServiceTests
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
[Fact]
public void ComputeHash_ReorderedCamelCaseMethodNames_ProducesSameHash()
{
// The method-name lookup is case-insensitive so that a naming-policy change at the
// producer (PascalCase anonymous type today) cannot silently degrade the sort into a
// no-op and let repository row order move the hash again.
var a = Command(externalSystems:
[
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null,
"[{\"name\":\"MoveOut\"},{\"name\":\"MoveIn\"}]")
]);
var b = Command(externalSystems:
[
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null,
"[{\"name\":\"MoveIn\"},{\"name\":\"MoveOut\"}]")
]);
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
[Fact]
public void ComputeHash_UnparseableMethodDefinitions_StillParticipatesInTheHash()
{
// Non-array / non-JSON payloads pass through verbatim: the order guarantee is lost
// (there is nothing to sort), but a content change must still move the hash.
var a = Command(externalSystems:
[new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "not json")]);
var b = Command(externalSystems:
[new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null, "not json either")]);
Assert.NotEqual(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
[Fact]
public void ComputeHash_DifferentDeploymentIdAndTimestamp_ProducesSameHash()
{