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 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(Mandatory, ParameterSetName = 'Explicit')] [ValidateNotNullOrEmpty()] [string] $Version,
        [string] $BuildId,
        [switch] $OnlyUpdateOnChangedSource,
        [string] $BranchPrefix = 'build/',
        [string] $Remote = 'origin',
        [ValidateRange(1, [int]::MaxValue)] [int] $MaxAttempts = 10,
        [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 }
    if ($OnlyUpdateOnChangedSource) { $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.' }

    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 }
    }

    # --- 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 }
        }

        # 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'."
}