Version
auth0-deploy-cli 8.42.0, and present on main at the time of writing.
What happens
A dry run reports no change at all when the only difference between the local assets and the tenant is the value of a field inside an array item. The asset is not listed in the dry-run table, its diff log is empty, and a subsequent import writes the change anyway.
We hit this on organization connections, where every meaningful flag lives inside organizations[].connections[]: flipping assign_membership_on_login, show_as_button or is_signup_enabled in the mirror produces a completely clean dry run.
Reproduction
No tenant needed, the comparison is pure:
const {
hasObjectDifferences,
calculateDryRunChanges,
} = require('auth0-deploy-cli/lib/tools/calculateDryRunChanges');
const local = {
name: 'acme',
connections: [{ connection_id: 'con_abc', assign_membership_on_login: true }],
};
const remote = {
name: 'acme',
connections: [{ connection_id: 'con_abc', assign_membership_on_login: false }],
};
hasObjectDifferences(local, remote, 'acme', 'organizations');
// false
calculateDryRunChanges({
type: 'organizations',
assets: [local],
existing: [remote],
identifiers: ['id', 'name'],
});
// { create: 0, update: 0, del: 0, conflicts: 0 }
End to end: export a tenant, change assign_membership_on_login on an organization connection in the exported file, a0deploy import --dry-run reports nothing, a0deploy import applies it.
Root cause
getObjectDifferences discards any value difference whose path contains an array index:
// src/tools/calculateDryRunChanges.ts:276-281
// Compare primitive values - omit array indices from path to reduce log noise
if (localValue !== remoteValue) {
let arrayPathRegex = new RegExp(/\[\d+\]/g);
if (!arrayPathRegex.test(currentPath)) {
const message = `Value difference for [${currentPath}]: local:${localValue} vs remote:${remoteValue}`;
differences.push(message);
}
}
Element-wise array comparison recurses with `${currentPath}[${index}]` (line 219-233), so every primitive comparison inside an array item carries an index in its path and every one of them is suppressed. Nothing else covers the case: Array length difference is only emitted when the lengths differ, and Array content difference is only emitted from the primitive-array branch (line 240-252), never for an array of objects.
Because differences comes back empty, hasObjectDifferences returns false (line 300-308), so the updatedAssets filter drops the asset at line 419-425 and it never reaches update.
The suppression is also inconsistent within the same function. A key that exists only inside the local array item is reported, because that branch sits above the guard:
Key [acme.connections[0].show_as_button] found in 'localObj' but not in 'remoteObj'.
So adding show_as_button is reported, while changing it from true to false is silent. That asymmetry suggests the guard is an oversight rather than a deliberate trade-off.
Expected
A changed field inside an array item is reported as a difference, and the asset is classified as an update.
Suggested fix
Remove the index guard, so the recursion reports value differences at their full path (acme.connections[0].assign_membership_on_login: local:true vs remote:false).
If the guard exists to suppress duplicate noise for primitive arrays, note that primitive arrays never reach this line: they are handled by the order-insensitive branch above and reported once as Array content difference found for key [...]. Narrowing the guard to that case, or dropping it entirely, both leave the noisy path covered.
Whatever the rendering, the important half is that differences is non-empty so the asset is classified as an update. A dry run that reports clean and an import that then writes is the failure mode worth closing.
Version
auth0-deploy-cli8.42.0, and present onmainat the time of writing.What happens
A dry run reports no change at all when the only difference between the local assets and the tenant is the value of a field inside an array item. The asset is not listed in the dry-run table, its diff log is empty, and a subsequent
importwrites the change anyway.We hit this on organization connections, where every meaningful flag lives inside
organizations[].connections[]: flippingassign_membership_on_login,show_as_buttonoris_signup_enabledin the mirror produces a completely clean dry run.Reproduction
No tenant needed, the comparison is pure:
End to end: export a tenant, change
assign_membership_on_loginon an organization connection in the exported file,a0deploy import --dry-runreports nothing,a0deploy importapplies it.Root cause
getObjectDifferencesdiscards any value difference whose path contains an array index:Element-wise array comparison recurses with
`${currentPath}[${index}]`(line 219-233), so every primitive comparison inside an array item carries an index in its path and every one of them is suppressed. Nothing else covers the case:Array length differenceis only emitted when the lengths differ, andArray content differenceis only emitted from the primitive-array branch (line 240-252), never for an array of objects.Because
differencescomes back empty,hasObjectDifferencesreturnsfalse(line 300-308), so theupdatedAssetsfilter drops the asset at line 419-425 and it never reachesupdate.The suppression is also inconsistent within the same function. A key that exists only inside the local array item is reported, because that branch sits above the guard:
So adding
show_as_buttonis reported, while changing it fromtruetofalseis silent. That asymmetry suggests the guard is an oversight rather than a deliberate trade-off.Expected
A changed field inside an array item is reported as a difference, and the asset is classified as an update.
Suggested fix
Remove the index guard, so the recursion reports value differences at their full path (
acme.connections[0].assign_membership_on_login: local:true vs remote:false).If the guard exists to suppress duplicate noise for primitive arrays, note that primitive arrays never reach this line: they are handled by the order-insensitive branch above and reported once as
Array content difference found for key [...]. Narrowing the guard to that case, or dropping it entirely, both leave the noisy path covered.Whatever the rendering, the important half is that
differencesis non-empty so the asset is classified as an update. A dry run that reports clean and an import that then writes is the failure mode worth closing.