Modules/businessdev.ALbuild.Core/Public/Invoke-BcBuildVersionStamp.ps1
|
function Invoke-BcBuildVersionStamp { <# .SYNOPSIS Stamps a build version into app.json and atomically claims a build branch for it. .DESCRIPTION The pipeline-level versioning step. It combines version stamping (Set-BcAppVersion) and build branch creation (New-BcBuildBranch) into one operation that is safe when several builds run in parallel - the classic race where two simultaneous jobs compute the same version. Concurrency is solved with a branch-push compare-and-swap: the build branch name encodes the version, so pushing it (without --force) is an atomic claim on the remote. If the push is rejected because the branch already exists (another build claimed that version), the version is incremented and the claim is retried, up to -MaxAttempts. Any other push failure throws. With -NoBuildBranch the version is written but nothing is committed or pushed (used by the local pipeline runner so a developer's run never claims a version or touches the remote). An explicit -Version is stamped as-is and claimed once (a conflict throws rather than retrying). .PARAMETER Path Repository root to search for app.json files, an app folder, or a single app.json. Default: the current location. .PARAMETER RepositoryRoot The git working tree. Defaults to -Path when it is a folder, otherwise its parent. .PARAMETER Schema Token schema for computing the version (see Set-BcAppVersion). Default 'major.minor.increment.0'. .PARAMETER SeedFromBuildBranch Consume the version from the latest build branch instead of claiming a new one (release/CD use). The release pipeline checks out the source branch, where app.json carries the product Major.Minor but a placeholder Build (e.g. 1.1.0.0). This reads the highest build/<Major>.<Minor>.* already on the remote (the CI build, e.g. 1.1.588.0), applies -Schema to it, and stamps every app to the result - e.g. schema 'latest.latest.latest.build-id' -> 1.1.588.<BuildId>. Read-only on the remote (no branch is claimed or pushed, so no Contribute permission is needed). If no matching build branch exists, it warns and falls back to the app.json-derived version. Schema-only. .PARAMETER Version An explicit version (overrides -Schema); stamped as-is and claimed once. .PARAMETER BuildId Value for the 'build-id' schema token. Defaults to BUILD_BUILDID, or '0'. .PARAMETER OnlyUpdateOnChangedSource Only stamp apps whose folder changed in the last commit; if nothing changed, no branch is claimed. .PARAMETER BranchPrefix Build branch name prefix. Default 'build/'. .PARAMETER Remote Remote to claim the branch on. Default 'origin'. .PARAMETER MaxAttempts Maximum claim attempts before giving up. Default 10. .PARAMETER NoBuildBranch Stamp only; do not commit, branch or push (dry-run for local runs). .PARAMETER UserName git identity name for the commit. Default 'ALbuild CI'. .PARAMETER UserEmail git identity email for the commit. Default 'albuild@365businessdev.com'. .EXAMPLE Invoke-BcBuildVersionStamp -Path . -Schema 'major.minor.increment.0' .EXAMPLE Invoke-BcBuildVersionStamp -Path . -NoBuildBranch # local dry-run .OUTPUTS PSCustomObject: Version, Branch, Attempts, Claimed, Committed, Apps. #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Schema')] [OutputType([PSCustomObject])] param( [Parameter(Position = 0)] [string] $Path = (Get-Location).Path, [string] $RepositoryRoot, [Parameter(ParameterSetName = 'Schema')] [ValidateNotNullOrEmpty()] [string] $Schema = 'major.minor.increment.0', [Parameter(ParameterSetName = 'Schema')] [switch] $SeedFromBuildBranch, [Parameter(Mandatory, ParameterSetName = 'Explicit')] [ValidateNotNullOrEmpty()] [string] $Version, [string] $BuildId, [switch] $OnlyUpdateOnChangedSource, [string] $BranchPrefix = 'build/', [string] $Remote = 'origin', [ValidateRange(1, [int]::MaxValue)] [int] $MaxAttempts = 99, [switch] $NoBuildBranch, [string] $UserName = 'ALbuild CI', [string] $UserEmail = 'albuild@365businessdev.com' ) if (-not (Test-Path -LiteralPath $Path)) { throw "Path '$Path' does not exist." } if (-not $RepositoryRoot) { $item = Get-Item -LiteralPath $Path $RepositoryRoot = if ($item.PSIsContainer) { $item.FullName } else { $item.Directory.FullName } } if (-not $PSBoundParameters.ContainsKey('BuildId') -or [string]::IsNullOrEmpty($BuildId)) { $BuildId = if ($env:BUILD_BUILDID) { $env:BUILD_BUILDID } else { '0' } } $isExplicit = $PSCmdlet.ParameterSetName -eq 'Explicit' # Helper: pick the highest version across the stamped apps as the build version. function Get-HighestVersion([object[]] $Stamped) { ($Stamped | ForEach-Object { ConvertTo-BcVersion $_.NewVersion } | Sort-Object -Descending | Select-Object -First 1) } # --- Initial stamp --------------------------------------------------------------------------- $stampArgs = @{ Path = $Path; BuildId = $BuildId } # -SeedFromBuildBranch stamps every app to the seeded version, so the per-app change filter does not # apply; this initial stamp only serves to read the product Major.Minor from app.json. if ($OnlyUpdateOnChangedSource -and -not $SeedFromBuildBranch) { $stampArgs['OnlyUpdateOnChangedSource'] = $true } if ($isExplicit) { $stampArgs['Version'] = $Version } else { $stampArgs['Schema'] = $Schema } $apps = @(Set-BcAppVersion @stampArgs) $current = Get-HighestVersion $apps if (-not $current) { throw 'No version could be determined from the stamped apps.' } # --- Seed from the highest build branch (release / CD) --------------------------------------- # Consume an existing build version rather than claim a new one. The release pipeline checks out the # source branch (app.json carries the product Major.Minor but a placeholder Build), so read the # highest build/<Major>.<Minor>.* the CI already claimed on the remote, apply the schema to it, and # stamp every app to the result. Read-only on the remote; no branch is claimed or pushed. if ($SeedFromBuildBranch) { $seed = Get-BcHighestClaimedVersion -RepositoryRoot $RepositoryRoot -Remote $Remote -BranchPrefix $BranchPrefix -Major $current.Major -Minor $current.Minor if ($seed) { $apps = @(Set-BcAppVersion -Path $Path -Schema $Schema -BaseVersion $seed -BuildId $BuildId) $current = Get-HighestVersion $apps Write-ALbuildLog -Level Success "Seeded release version $current from the highest build branch '$BranchPrefix$seed' on '$Remote'." } else { Write-ALbuildLog -Level Warning "SeedFromBuildBranch: no '$BranchPrefix$($current.Major).$($current.Minor).*' branch on '$Remote'; using the app.json-derived version $current." } return [PSCustomObject]@{ Version = $current.ToString(); Branch = $null; Attempts = 0; Claimed = $false; Committed = $false; Apps = $apps } } if ($OnlyUpdateOnChangedSource -and -not ($apps | Where-Object { $_.Changed })) { Write-ALbuildLog -Level Information 'No app source changed; skipping the build branch claim.' return [PSCustomObject]@{ Version = $current.ToString(); Branch = $null; Attempts = 0; Claimed = $false; Committed = $false; Apps = $apps } } if ($NoBuildBranch) { Write-ALbuildLog -Level Information "Stamped version $current (no build branch; -NoBuildBranch)." return [PSCustomObject]@{ Version = $current.ToString(); Branch = $null; Attempts = 0; Claimed = $false; Committed = $false; Apps = $apps } } if (-not $PSCmdlet.ShouldProcess("$Remote/$BranchPrefix$current", 'Claim build branch')) { return [PSCustomObject]@{ Version = $current.ToString(); Branch = "$BranchPrefix$current"; Attempts = 0; Claimed = $false; Committed = $false; Apps = $apps } } # --- Seed from the remote -------------------------------------------------------------------- # Build branches are never merged back, so app.json keeps producing the same low start version and # each build would re-probe past every previously claimed branch one push at a time. Read the # highest build/<Major>.<Minor>.* already on the remote and start just above it; the claim loop # below still increments on a real conflict, so genuinely parallel builds are still covered. # (Schema path only: an explicit -Version is claimed exactly once.) if (-not $isExplicit) { $highest = Get-BcHighestClaimedVersion -RepositoryRoot $RepositoryRoot -Remote $Remote -BranchPrefix $BranchPrefix -Major $current.Major -Minor $current.Minor if ($highest -and $highest -ge $current) { $next = [version]("{0}.{1}.{2}.{3}" -f $highest.Major, $highest.Minor, ([Math]::Max($highest.Build, 0) + 1), [Math]::Max($highest.Revision, 0)) Write-ALbuildLog -Level Information "Highest claimed build branch for $($current.Major).$($current.Minor) on '$Remote' is $highest; starting at $next." $current = $next $apps = @(Set-BcAppVersion -Path $Path -Version $current.ToString() -BuildId $BuildId) } } # --- Claim loop (compare-and-swap on the build branch) --------------------------------------- $branchArgs = @{ RepositoryRoot = $RepositoryRoot; BranchPrefix = $BranchPrefix; Remote = $Remote; UserName = $UserName; UserEmail = $UserEmail } for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { $branch = New-BcBuildBranch -Version $current.ToString() @branchArgs if ($branch.Pushed) { Write-ALbuildLog -Level Success "Claimed build version $current on attempt $attempt." return [PSCustomObject]@{ Version = $current.ToString(); Branch = $branch.Branch; Attempts = $attempt; Claimed = $true; Committed = $branch.Committed; Apps = $apps } } # The identity cannot push a build branch (no Contribute permission). The version is already # stamped into app.json, so degrade to a stamp-only build rather than failing the pipeline - # New-BcBuildBranch has logged an actionable warning. The build is no longer parallel-safe, # but a permission-limited pipeline that cannot claim branches gets a working build. if ($branch.PSObject.Properties['PermissionDenied'] -and $branch.PermissionDenied) { return [PSCustomObject]@{ Version = $current.ToString(); Branch = $null; Attempts = $attempt; Claimed = $false; Committed = $branch.Committed; Apps = $apps } } # Only a conflict (ref already claimed) reaches here; hard failures threw inside New-BcBuildBranch. if ($isExplicit) { throw "Build version $Version is already claimed on '$Remote'. Choose a different version." } $next = [version]("{0}.{1}.{2}.{3}" -f $current.Major, $current.Minor, ([Math]::Max($current.Build, 0) + 1), [Math]::Max($current.Revision, 0)) Write-ALbuildLog -Level Warning "Version $current was already claimed; retrying as $next." $current = $next $apps = @(Set-BcAppVersion -Path $Path -Version $current.ToString() -BuildId $BuildId) } throw "Could not claim a build version after $MaxAttempts attempt(s) on '$Remote'." } |