src/Publish-CciModuleToBlob.ps1

function Publish-CciModuleToBlob {
<#
.SYNOPSIS
    Mirrors a built module into the tenant distribution store and updates its index.
.DESCRIPTION
    The tenant Azure Artifacts feed is the canonical publish target, but reading
    it requires an Azure DevOps org entitlement and a paid Basic licence per user
    (Stakeholder has no Artifacts access). Machine builders have neither - they
    hold Azure RBAC on the tenant store instead. This mirrors the same published
    module there so `Install-CciModule` can serve them without any Azure DevOps
    identity.
 
    Uses Blob REST with an Entra token (no Az PowerShell). Requires
    Storage Blob Data Contributor on the container.
.PARAMETER Path
    Folder containing the built module (the one holding its .psd1).
.PARAMETER Name
    Published module name, e.g. cciit.wincustpayload. Defaults to the manifest's
    base name.
.PARAMETER Version
    Version to publish under. Defaults to the manifest's ModuleVersion.
.PARAMETER Tenant
    Tenant whose distribution store to publish to. Defaults to defaultFeed.
.EXAMPLE
    Publish-CciModuleToBlob -Path .\out\stage\cciit.wincustpayload
#>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path $_ -PathType Container })]
        [string]$Path,
        [string]$Name,
        [string]$Version,
        [string]$Tenant
    )

    if (-not $Tenant) { $Tenant = (Get-CciGetConfig).defaultFeed }
    $feed = (_Resolve-CciGetFeed -Tenant $Tenant)[0]

    $psd1 = Get-ChildItem $Path -Filter '*.psd1' | Select-Object -First 1
    if (-not $psd1) { throw "cciget: no module manifest found under '$Path'." }
    $manifest = Import-PowerShellDataFile $psd1.FullName
    if (-not $Name)    { $Name    = $psd1.BaseName }
    if (-not $Version) { $Version = "$($manifest.ModuleVersion)" }

    $ctx = _Connect-CciBlobStore -Feed $feed
    if (-not $ctx) { throw "cciget: could not connect to the distribution store for '$($feed.name)'." }

    # @() so $files.Count is readable for one file too: this module loads under
    # Set-StrictMode -Version Latest, where .Count on a bare FileInfo throws.
    $files = @(Get-ChildItem $Path -Recurse -File)
    if (-not $files) { throw "cciget: '$Path' contains no files." }
    $prefix = "$($feed.blobPrefix)/$Name/$Version"

    if (-not $PSCmdlet.ShouldProcess("$($feed.blobAccount)/$($feed.blobContainer)/$prefix", "upload $($files.Count) files")) { return }

    $rootLen = (Get-Item $Path).FullName.Length + 1
    foreach ($f in $files) {
        $rel = $f.FullName.Substring($rootLen) -replace '\\', '/'
        $null = _Invoke-CciBlobRequest -Context $ctx -Operation Upload `
            -Container $feed.blobContainer -Blob "$prefix/$rel" -Path $f.FullName
    }
    Write-Host "cciget: mirrored $Name $Version ($($files.Count) files) to the '$($feed.name)' distribution store."

    # Update the index (read-modify-write; pipelines publish one module at a time).
    # Only a store that ANSWERED and has no index may be treated as empty. Any
    # other failure has to stop the publish: starting a fresh index from a
    # rejected read would upload one holding this module alone and drop every
    # other module out of the store.
    $indexResult = _Get-CciBlobModuleIndexResult -Feed $feed -Context $ctx
    switch ($indexResult.Status) {
        'ok'     { $index = $indexResult.Index }
        'absent' { $index = [pscustomobject]@{ modules = [pscustomobject]@{} } }
        default  { throw (_Get-CciStoreIndexProblem -Feed $feed -Result $indexResult) }
    }
    if (-not $index.PSObject.Properties['modules'] -or -not $index.modules) {
        $index | Add-Member -NotePropertyName modules -NotePropertyValue ([pscustomobject]@{}) -Force
    }
    $entry = [pscustomobject]@{ latest = $Version; updated = (Get-Date).ToUniversalTime().ToString('o') }
    $index.modules | Add-Member -NotePropertyName $Name -NotePropertyValue $entry -Force

    $tmp = Join-Path ([IO.Path]::GetTempPath()) "cciget-index-$([guid]::NewGuid().ToString('n')).json"
    try {
        [System.IO.File]::WriteAllText($tmp, ($index | ConvertTo-Json -Depth 6), (New-Object System.Text.UTF8Encoding $false))
        $null = _Invoke-CciBlobRequest -Context $ctx -Operation Upload `
            -Container $feed.blobContainer -Blob "$($feed.blobPrefix)/index.json" -Path $tmp
    } finally {
        Remove-Item $tmp -Force -ErrorAction SilentlyContinue
    }
    Write-Host "cciget: index updated - $Name latest = $Version."
}