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
@@ -135,6 +135,13 @@ public sealed class ArtifactRevisionHashService
/// by name (ordinal), so the repository's row order cannot leak into the hash. Anything
/// that is not a parseable JSON array is returned verbatim — the hash still changes when
/// the payload does, which is the only guarantee this method owes.
/// <para>
/// The name property is matched CASE-INSENSITIVELY. Today's producer
/// (<c>ArtifactDeploymentService.FetchGlobalArtifactsAsync</c>) serializes an anonymous
/// type, so the key is PascalCase <c>Name</c> — but a future camelCase naming policy there
/// would otherwise silently turn this sort into a no-op and let row order move the hash
/// again, with nothing failing.
/// </para>
/// </summary>
private static string? CanonicalizeMethodDefinitions(string? methodDefinitionsJson)
{
@@ -150,13 +157,7 @@ public sealed class ArtifactRevisionHashService
var ordered = document.RootElement
.EnumerateArray()
.Select(element => element.Clone())
.OrderBy(element =>
element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty("Name", out var name)
&& name.ValueKind == JsonValueKind.String
? name.GetString() ?? string.Empty
: string.Empty,
StringComparer.Ordinal)
.OrderBy(MethodSortKey, StringComparer.Ordinal)
.ToList();
return JsonSerializer.Serialize(ordered, CanonicalJsonOptions);
@@ -167,6 +168,31 @@ public sealed class ArtifactRevisionHashService
}
}
/// <summary>
/// Sort key for one serialized external-system method: its name property, matched
/// case-insensitively so the key survives a naming-policy change at the producer.
/// Elements that are not objects, or carry no string name, sort first under an empty key —
/// their relative order is then whatever the payload gave, which is the best a nameless
/// element allows.
/// </summary>
private static string MethodSortKey(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
return string.Empty;
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, "Name", StringComparison.OrdinalIgnoreCase))
{
return property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: string.Empty;
}
}
return string.Empty;
}
// Internal types for deterministic serialization. Properties MUST be declared in
// alphabetical (ordinal) order — System.Text.Json emits them in declaration order
// and does not sort. See the DETERMINISM CONTRACT note on the class summary.