diff --git a/.github/tests/README.md b/.github/tests/README.md
index 5a447e1e..626c3553 100644
--- a/.github/tests/README.md
+++ b/.github/tests/README.md
@@ -32,3 +32,17 @@ The tests use a set of environemnts to managed by the ALZ team. These are:
- Organisation: microsoft-azure-landing-zones-cd-tests
- GiHub
- Organisation: microsoft-azure-landingzones-cd-tests
+
+## Validation Script
+
+Use the script below to validate deterministic deployment stack naming behavior for Bug #4062:
+
+- Script: `.github/tests/scripts/validate-deployment-stack-name-hash.ps1`
+- Run from repo root: `pwsh ./.github/tests/scripts/validate-deployment-stack-name-hash.ps1`
+
+This validation confirms:
+
+1. Legacy names are preserved when `prefix-base` length is within Azure's 64-character limit.
+2. Different long names (for example `stage-three` and `stage-four`) produce different final stack names.
+3. Final stack names remain within Azure's 64-character limit.
+4. Very long prefixes are trimmed safely while preserving the hash suffix when hash mode is required.
diff --git a/.github/tests/scripts/validate-deployment-stack-name-hash.ps1 b/.github/tests/scripts/validate-deployment-stack-name-hash.ps1
new file mode 100644
index 00000000..38dc57d2
--- /dev/null
+++ b/.github/tests/scripts/validate-deployment-stack-name-hash.ps1
@@ -0,0 +1,167 @@
+param()
+
+function Get-Sha256HashPrefix {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$InputString,
+ [Parameter(Mandatory = $true)]
+ [int]$Length
+ )
+
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($InputString))
+ } finally {
+ $sha256.Dispose()
+ }
+
+ return [System.BitConverter]::ToString($hashBytes).Replace("-", "").Substring(0, $Length).ToLower()
+}
+
+function New-RemoteDeterministicDeploymentStackName {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$DeploymentPrefix,
+ [Parameter(Mandatory = $true)]
+ [string]$OriginalName
+ )
+
+ $normalizedBase = $OriginalName
+ if ([string]::IsNullOrWhiteSpace($normalizedBase)) {
+ $normalizedBase = "deployment"
+ }
+ $normalizedBase = $normalizedBase.Replace(" ", "-")
+
+ $hashLength = 10
+ $hash = Get-Sha256HashPrefix -InputString "$DeploymentPrefix|$normalizedBase" -Length $hashLength
+
+ $legacyName = "$DeploymentPrefix-$normalizedBase"
+ if ($legacyName.Length -le 64) {
+ return $legacyName
+ }
+
+ $safePrefix = $DeploymentPrefix
+ $prefixMaxLength = [Math]::Max(1, 64 - $hashLength - 3)
+ if ($safePrefix.Length -gt $prefixMaxLength) {
+ $safePrefix = $safePrefix.Substring(0, $prefixMaxLength)
+ }
+
+ $baseMaxLength = [Math]::Max(1, 64 - $safePrefix.Length - $hashLength - 2)
+ $safeBase = $normalizedBase
+ if ($safeBase.Length -gt $baseMaxLength) {
+ $safeBase = $safeBase.Substring(0, $baseMaxLength)
+ }
+
+ return "$safePrefix-$safeBase-$hash"
+}
+
+$localPrefix = "alzrishabh2705-long-management-group-id"
+function New-LocalDeterministicDeploymentStackName {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$DeploymentPrefix,
+ [Parameter(Mandatory = $true)]
+ [string]$OriginalName
+ )
+
+ $normalizedBase = $OriginalName
+ if ([string]::IsNullOrWhiteSpace($normalizedBase)) {
+ $normalizedBase = "deployment"
+ }
+ $normalizedBase = $normalizedBase.Replace(" ", "-")
+
+ if ($normalizedBase.Length -le 64) {
+ return $normalizedBase
+ }
+
+ $hashLength = 10
+ $hash = Get-Sha256HashPrefix -InputString "$DeploymentPrefix|$normalizedBase" -Length $hashLength
+
+ $safePrefix = $DeploymentPrefix
+ $prefixMaxLength = [Math]::Max(1, 64 - $hashLength - 3)
+ if ($safePrefix.Length -gt $prefixMaxLength) {
+ $safePrefix = $safePrefix.Substring(0, $prefixMaxLength)
+ }
+
+ $baseMaxLength = [Math]::Max(1, 64 - $safePrefix.Length - $hashLength - 2)
+ $safeBase = $normalizedBase
+ if ($safeBase.Length -gt $baseMaxLength) {
+ $safeBase = $safeBase.Substring(0, $baseMaxLength)
+ }
+
+ return "$safePrefix-$safeBase-$hash"
+}
+
+$shortPrefix = "alz-short"
+$shortBase = "deploy-stage-one"
+$legacyExpected = "$shortPrefix-$shortBase"
+$shortNameResult = New-RemoteDeterministicDeploymentStackName -DeploymentPrefix $shortPrefix -OriginalName $shortBase
+Write-Host "Legacy-compatible Name: $shortNameResult"
+
+if ($shortNameResult -ne $legacyExpected) {
+ throw "Validation failed: expected legacy name '$legacyExpected' when length is <= 64."
+}
+
+if ($shortNameResult -match "-[a-f0-9]{10}$") {
+ throw "Validation failed: hash suffix should not be added when legacy name length is <= 64."
+}
+
+$prefix = "alzrishabh2705-long-management-group-id"
+$nameStageThree = "governance-landingzones-platform-deployment-stage-three"
+$nameStageFour = "governance-landingzones-platform-deployment-stage-four"
+
+$stackNameStageThree = New-RemoteDeterministicDeploymentStackName -DeploymentPrefix $prefix -OriginalName $nameStageThree
+$stackNameStageFour = New-RemoteDeterministicDeploymentStackName -DeploymentPrefix $prefix -OriginalName $nameStageFour
+
+Write-Host "Stage Three Name: $stackNameStageThree"
+Write-Host "Stage Four Name: $stackNameStageFour"
+
+if ($stackNameStageThree -eq $stackNameStageFour) {
+ throw "Validation failed: stage-three and stage-four produced the same deployment stack name."
+}
+
+if ($stackNameStageThree.Length -gt 64 -or $stackNameStageFour.Length -gt 64) {
+ throw "Validation failed: one or more deployment stack names exceeded the 64 character limit."
+}
+
+if ($stackNameStageThree -notmatch "-[a-f0-9]{10}$" -or $stackNameStageFour -notmatch "-[a-f0-9]{10}$") {
+ throw "Validation failed: long names should use the deterministic hash suffix."
+}
+
+$veryLongPrefix = "alzrishabh2705-super-long-management-group-prefix-with-extra-segments-over-limit"
+$longPrefixName = New-RemoteDeterministicDeploymentStackName -DeploymentPrefix $veryLongPrefix -OriginalName $nameStageThree
+Write-Host "Long Prefix Name: $longPrefixName"
+
+if ($longPrefixName.Length -gt 64) {
+ throw "Validation failed: long-prefix deployment stack name exceeded 64 characters."
+}
+
+if ($longPrefixName -notmatch "-[a-f0-9]{10}$") {
+ throw "Validation failed: deployment stack name does not end with expected lowercase hash suffix."
+}
+
+$localShortName = "platform-stage-one"
+$localShortNameResult = New-LocalDeterministicDeploymentStackName -DeploymentPrefix $localPrefix -OriginalName $localShortName
+Write-Host "Local Legacy-compatible Name: $localShortNameResult"
+
+if ($localShortNameResult -ne $localShortName) {
+ throw "Validation failed: local short deployment stack names should remain unchanged."
+}
+
+if ($localShortNameResult -match "-[a-f0-9]{10}$") {
+ throw "Validation failed: local short deployment stack names should not receive a hash suffix."
+}
+
+$localLongName = "governance-landingzones-platform-deployment-stage-three-with-extra-segments-over-the-azure-name-limit"
+$localLongNameResult = New-LocalDeterministicDeploymentStackName -DeploymentPrefix $localPrefix -OriginalName $localLongName
+Write-Host "Local Long Name: $localLongNameResult"
+
+if ($localLongNameResult.Length -gt 64) {
+ throw "Validation failed: local long deployment stack name exceeded 64 characters."
+}
+
+if ($localLongNameResult -notmatch "-[a-f0-9]{10}$") {
+ throw "Validation failed: local long deployment stack names should use the deterministic hash suffix."
+}
+
+Write-Host "Validation passed: legacy-compatible naming is preserved and hash-suffix naming is unique and length-safe when required."
diff --git a/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml b/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml
index 087c85e1..8cd8fd5a 100644
--- a/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml
+++ b/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml
@@ -70,12 +70,42 @@ steps:
# Generate deployment stack name
$intRootMgId = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX
$deploymentPrefix = $intRootMgId
- $deploymentNameBase = "$${{ parameters.name }}".Replace(" ", "-")
- $deploymentNameMaxLength = 64 - $deploymentPrefix.Length - 1
- if ($deploymentNameBase.Length -gt $deploymentNameMaxLength) {
- $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameMaxLength)
+ $deploymentNameOriginalBase = "$${{ parameters.name }}"
+ if ([string]::IsNullOrWhiteSpace($deploymentNameOriginalBase)) {
+ $deploymentNameOriginalBase = "deployment"
+ }
+ $deploymentNameOriginalBase = $deploymentNameOriginalBase.Replace(" ", "-")
+
+ $deploymentNameHashLength = 10
+ $hashInput = "$deploymentPrefix|$deploymentNameOriginalBase"
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($hashInput))
+ } finally {
+ $sha256.Dispose()
+ }
+ $deploymentNameSuffix = [System.BitConverter]::ToString($hashBytes).Replace("-", "").Substring(0, $deploymentNameHashLength).ToLower()
+
+ $deploymentNameLegacy = "$deploymentPrefix-$deploymentNameOriginalBase"
+
+ if ($deploymentNameLegacy.Length -le 64) {
+ # Preserve legacy naming to keep backward compatibility when length already fits.
+ $deploymentName = $deploymentNameLegacy
+ } else {
+ # Keep room for "--" and trim an oversized prefix safely.
+ $deploymentPrefixMaxLength = [Math]::Max(1, 64 - $deploymentNameHashLength - 3)
+ if ($deploymentPrefix.Length -gt $deploymentPrefixMaxLength) {
+ $deploymentPrefix = $deploymentPrefix.Substring(0, $deploymentPrefixMaxLength)
+ }
+
+ $deploymentNameBaseMaxLength = [Math]::Max(1, 64 - $deploymentPrefix.Length - $deploymentNameHashLength - 2)
+ $deploymentNameBase = $deploymentNameOriginalBase
+ if ($deploymentNameBase.Length -gt $deploymentNameBaseMaxLength) {
+ $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameBaseMaxLength)
+ }
+
+ $deploymentName = "$deploymentPrefix-$deploymentNameBase-$deploymentNameSuffix"
}
- $deploymentName = "$deploymentPrefix-$deploymentNameBase"
$modeText = if ($whatIfEnabled) { "What-If" } else { "Deployment Stack" }
diff --git a/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml b/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml
index 22b85b58..aa31bbec 100644
--- a/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml
+++ b/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml
@@ -65,12 +65,42 @@ runs:
# Generate deployment stack name
$intRootMgId = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX
$deploymentPrefix = $intRootMgId
- $deploymentNameBase = ($env:NAME).Replace(" ", "-")
- $deploymentNameMaxLength = 64 - $deploymentPrefix.Length - 1
- if ($deploymentNameBase.Length -gt $deploymentNameMaxLength) {
- $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameMaxLength)
+ $deploymentNameOriginalBase = $env:NAME
+ if ([string]::IsNullOrWhiteSpace($deploymentNameOriginalBase)) {
+ $deploymentNameOriginalBase = "deployment"
+ }
+ $deploymentNameOriginalBase = $deploymentNameOriginalBase.Replace(" ", "-")
+
+ $deploymentNameHashLength = 10
+ $hashInput = "$deploymentPrefix|$deploymentNameOriginalBase"
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($hashInput))
+ } finally {
+ $sha256.Dispose()
+ }
+ $deploymentNameSuffix = [System.BitConverter]::ToString($hashBytes).Replace("-", "").Substring(0, $deploymentNameHashLength).ToLower()
+
+ $deploymentNameLegacy = "$deploymentPrefix-$deploymentNameOriginalBase"
+
+ if ($deploymentNameLegacy.Length -le 64) {
+ # Preserve legacy naming to keep backward compatibility when length already fits.
+ $deploymentName = $deploymentNameLegacy
+ } else {
+ # Keep room for "--" and trim an oversized prefix safely.
+ $deploymentPrefixMaxLength = [Math]::Max(1, 64 - $deploymentNameHashLength - 3)
+ if ($deploymentPrefix.Length -gt $deploymentPrefixMaxLength) {
+ $deploymentPrefix = $deploymentPrefix.Substring(0, $deploymentPrefixMaxLength)
+ }
+
+ $deploymentNameBaseMaxLength = [Math]::Max(1, 64 - $deploymentPrefix.Length - $deploymentNameHashLength - 2)
+ $deploymentNameBase = $deploymentNameOriginalBase
+ if ($deploymentNameBase.Length -gt $deploymentNameBaseMaxLength) {
+ $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameBaseMaxLength)
+ }
+
+ $deploymentName = "$deploymentPrefix-$deploymentNameBase-$deploymentNameSuffix"
}
- $deploymentName = "$deploymentPrefix-$deploymentNameBase"
$modeText = if ($whatIfEnabled) { "What-If" } else { "Deployment Stack" }
diff --git a/alz/local/scripts-bicep/bicep-deploy.ps1 b/alz/local/scripts-bicep/bicep-deploy.ps1
index b36c9e38..6619ff90 100644
--- a/alz/local/scripts-bicep/bicep-deploy.ps1
+++ b/alz/local/scripts-bicep/bicep-deploy.ps1
@@ -31,7 +31,42 @@ Write-Host "Resource Group Name: $resourceGroupName" -ForegroundColor DarkGray
Write-Host "Location: $location" -ForegroundColor DarkGray
Write-Host "Deployment Type: $deploymentType" -ForegroundColor DarkGray
-$deploymentName = $name
+$deploymentPrefix = $intRootMgId
+$deploymentNameOriginalBase = $name
+if ([string]::IsNullOrWhiteSpace($deploymentNameOriginalBase)) {
+ $deploymentNameOriginalBase = "deployment"
+}
+$deploymentNameOriginalBase = $deploymentNameOriginalBase.Replace(" ", "-")
+$deploymentNameHashLength = 10
+$hashInput = "$deploymentPrefix|$deploymentNameOriginalBase"
+$sha256 = [System.Security.Cryptography.SHA256]::Create()
+try {
+ $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($hashInput))
+} finally {
+ $sha256.Dispose()
+}
+$deploymentNameSuffix = [System.BitConverter]::ToString($hashBytes).Replace("-", "").Substring(0, $deploymentNameHashLength).ToLower()
+
+$deploymentNameLegacy = $deploymentNameOriginalBase
+
+if ($deploymentNameLegacy.Length -le 64) {
+ # Preserve legacy local naming to keep backward compatibility when length already fits.
+ $deploymentName = $deploymentNameLegacy
+} else {
+ # Keep room for "--" and trim an oversized prefix safely.
+ $deploymentPrefixMaxLength = [Math]::Max(1, 64 - $deploymentNameHashLength - 3)
+ if ($deploymentPrefix.Length -gt $deploymentPrefixMaxLength) {
+ $deploymentPrefix = $deploymentPrefix.Substring(0, $deploymentPrefixMaxLength)
+ }
+
+ $deploymentNameBaseMaxLength = [Math]::Max(1, 64 - $deploymentPrefix.Length - $deploymentNameHashLength - 2)
+ $deploymentNameBase = $deploymentNameOriginalBase
+ if ($deploymentNameBase.Length -gt $deploymentNameBaseMaxLength) {
+ $deploymentNameBase = $deploymentNameBase.Substring(0, $deploymentNameBaseMaxLength)
+ }
+
+ $deploymentName = "$deploymentPrefix-$deploymentNameBase-$deploymentNameSuffix"
+}
Write-Host "Deployment Stack Name: $deploymentName"
Write-Host ""