Modules/businessdev.ALbuild.Apps/Public/Set-BcAppVersion.ps1

function Set-BcAppVersion {
    <#
    .SYNOPSIS
        Sets the version of one or more Business Central apps by updating their app.json.
 
    .DESCRIPTION
        Updates the top-level "version" field of every app.json found under -Path (recursively,
        excluding symbol/package folders) - or a single app.json if -Path points directly at one.
        The new version is either an explicit -Version or computed from a token -Schema applied to
        each app's current version (see the schema tokens below). The version value is rewritten in
        place with a targeted text replacement so the rest of the file (ordering, indentation,
        comments-as-properties) is preserved; only the top-level version value changes.
 
        Schema tokens (one per dotted position, case-insensitive): a number = literal; 'increment'
        = current component + 1; 'build-id' = the -BuildId value; 'major'/'minor'/'build'/'revision'/
        'latest'/'keep' = keep the current component. Example: 'major.minor.increment.0'.
 
        This cmdlet performs no git or pipeline I/O - it only reads/writes app.json (and, for
        -OnlyUpdateOnChangedSource, asks git whether an app folder changed in the last commit).
        Use Invoke-BcBuildVersionStamp for the pipeline-level "stamp + claim a build branch" flow.
 
    .PARAMETER Path
        A repository root to search recursively, an app folder, or a single app.json. Default: the
        current location.
 
    .PARAMETER Schema
        The token schema used to compute each new version from its current value. Default
        'major.minor.increment.0'. Ignored when -Version is given.
 
    .PARAMETER Version
        An explicit version applied to every matched app (overrides -Schema).
 
    .PARAMETER BuildId
        Value substituted for the 'build-id' schema token. Defaults to the BUILD_BUILDID environment
        variable, or '0' when unset.
 
    .PARAMETER OnlyUpdateOnChangedSource
        Skip apps whose folder did not change in the most recent commit (git diff HEAD~1..HEAD).
        Requires a git working tree at -Path (or the app's repository).
 
    .EXAMPLE
        Set-BcAppVersion -Path . -Schema 'major.minor.build-id.0' -BuildId $env:BUILD_BUILDID
 
    .EXAMPLE
        Set-BcAppVersion -Path .\app -Version '2.3.0.0'
 
    .OUTPUTS
        PSCustomObject per app: AppJsonPath, AppFolder, PreviousVersion, NewVersion, Changed, Skipped.
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Schema')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Position = 0)]
        [string] $Path = (Get-Location).Path,

        [Parameter(ParameterSetName = 'Schema')]
        [ValidateNotNullOrEmpty()]
        [string] $Schema = 'major.minor.increment.0',

        [Parameter(Mandatory, ParameterSetName = 'Explicit')]
        [ValidateNotNullOrEmpty()]
        [string] $Version,

        [string] $BuildId,

        [switch] $OnlyUpdateOnChangedSource
    )

    if (-not (Test-Path -LiteralPath $Path)) { throw "Path '$Path' does not exist." }

    if (-not $PSBoundParameters.ContainsKey('BuildId') -or [string]::IsNullOrEmpty($BuildId)) {
        $BuildId = if ($env:BUILD_BUILDID) { $env:BUILD_BUILDID } else { '0' }
    }

    # Collect the app.json files in scope.
    $item = Get-Item -LiteralPath $Path
    if ($item.PSIsContainer) {
        $repositoryRoot = $item.FullName
        $appJsonFiles = @(Get-ChildItem -LiteralPath $item.FullName -Filter 'app.json' -File -Recurse -ErrorAction SilentlyContinue |
                Where-Object { $_.FullName -notmatch '[\\/](\.alpackages|\.altemplates|\.snapshots|\.output)[\\/]' })
    }
    elseif ($item.Name -eq 'app.json') {
        $repositoryRoot = $item.Directory.FullName
        $appJsonFiles = @($item)
    }
    else {
        throw "Path '$Path' is neither a folder nor an app.json file."
    }

    if ($appJsonFiles.Count -eq 0) { throw "No app.json found under '$Path'." }

    $versionPattern = [regex] '("version"\s*:\s*")[^"]*(")'
    $utf8NoBom = [System.Text.UTF8Encoding]::new($false)

    foreach ($file in $appJsonFiles) {
        $appFolder = $file.Directory.FullName

        $raw = [System.IO.File]::ReadAllText($file.FullName)
        $json = $raw | ConvertFrom-Json
        if (-not ($json.PSObject.Properties.Name -contains 'version')) {
            throw "'$($file.FullName)' has no top-level 'version' property."
        }
        $previous = ConvertTo-BcVersion ([string]$json.version)

        if ($OnlyUpdateOnChangedSource -and (-not (Test-BcSourceChanged -RepositoryRoot $repositoryRoot -AppFolder $appFolder))) {
            Write-ALbuildLog -Level Information "No source changes for '$appFolder'; keeping version $previous."
            [PSCustomObject]@{
                AppJsonPath     = $file.FullName
                AppFolder       = $appFolder
                PreviousVersion = $previous.ToString()
                NewVersion      = $previous.ToString()
                Changed         = $false
                Skipped         = $true
            }
            continue
        }

        $new = if ($PSCmdlet.ParameterSetName -eq 'Explicit') {
            ConvertTo-BcVersion $Version
        }
        else {
            Get-BcSchemaVersion -Current $previous -Schema $Schema -BuildId $BuildId
        }
        $newString = $new.ToString()

        $changed = $false
        if ($newString -ne $previous.ToString()) {
            if ($PSCmdlet.ShouldProcess($file.FullName, "Set version $previous -> $newString")) {
                # Replace only the first (top-level) "version" value; dependency versions are untouched.
                $updated = $versionPattern.Replace($raw, { param($m) "$($m.Groups[1].Value)$newString$($m.Groups[2].Value)" }, 1)
                if ($updated -eq $raw) { throw "Could not locate the top-level version value in '$($file.FullName)'." }
                [System.IO.File]::WriteAllText($file.FullName, $updated, $utf8NoBom)
                $changed = $true
                Write-ALbuildLog -Level Success "Set '$appFolder' version $previous -> $newString."
            }
        }
        else {
            Write-ALbuildLog -Level Information "'$appFolder' already at version $newString."
        }

        [PSCustomObject]@{
            AppJsonPath     = $file.FullName
            AppFolder       = $appFolder
            PreviousVersion = $previous.ToString()
            NewVersion      = $newString
            Changed         = $changed
            Skipped         = $false
        }
    }
}