scripts/internal/module-packaging.ps1

# Shared module packaging: staging, stamping and identity.
#
# Extracted from invoke-module-release.ps1 on 2026-08-21 so the LOCAL INSTALL path and the RELEASE
# path run the same code. Before this there was no installer at all: invoke-module-release.ps1
# deletes its stage in a finally - correct for a release tool - so a dry run proved packaging worked
# and left nothing installable. Every local install was hand-assembled by extracting these functions
# from that script via an AST parse and re-running the sequence without the cleanup.
#
# That unsupported path is not a theoretical risk. It produced an install whose build stamp read one
# commit while its files carried another, caught only because someone diffed 82 files by hand.
#
# Moved VERBATIM rather than rewritten. A reworded second copy is how a release path and an install
# path drift apart, and the point of this file is that they cannot.

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Write-ReleaseInfo {
    param([string]$Message)
    Write-Host "[release] $Message" -ForegroundColor Cyan
}

function Get-SpecrewVersionFromConfig {
    param([Parameter(Mandatory = $true)][string]$ConfigPath)

    if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) {
        throw "Missing Specrew config '$ConfigPath'."
    }

    foreach ($line in Get-Content -LiteralPath $ConfigPath -Encoding UTF8) {
        if ($line -match '^\s*specrew_version:\s*"?(?<version>[^"#]+?)"?\s*$') {
            return $Matches.version.Trim()
        }
    }

    throw "Could not read 'specrew_version' from '$ConfigPath'."
}

function Set-SpecrewManifestReleaseMetadata {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')]
    param(
        [Parameter(Mandatory = $true)][string]$ManifestPath,
        [Parameter(Mandatory = $true)][string]$Version,
        [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Prerelease
    )

    if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) {
        throw "Missing module manifest '$ManifestPath'."
    }

    $content = Get-Content -LiteralPath $ManifestPath -Raw -Encoding UTF8
    $moduleVersionPattern = "(?m)^(\s*ModuleVersion\s*=\s*)'[^']*'\s*$"
    $moduleVersionMatch = [regex]::Match($content, $moduleVersionPattern)
    if (-not $moduleVersionMatch.Success) {
        throw "Could not locate ModuleVersion in '$ManifestPath'."
    }

    $updated = [regex]::Replace($content, $moduleVersionPattern, ('$1''{0}''' -f $Version), 1)
    $prereleasePattern = "(?m)^(\s*Prerelease\s*=\s*)'[^']*'\s*$"
    $prereleaseMatch = [regex]::Match($updated, $prereleasePattern)
    if (-not $prereleaseMatch.Success) {
        throw "Could not locate PrivateData.PSData.Prerelease in '$ManifestPath'."
    }

    $withPrerelease = [regex]::Replace($updated, $prereleasePattern, ('$1''{0}''' -f $Prerelease), 1)
    if ($PSCmdlet.ShouldProcess($ManifestPath, ("Set ModuleVersion to {0} with Prerelease '{1}'" -f $Version, $Prerelease))) {
        [System.IO.File]::WriteAllText($ManifestPath, $withPrerelease, [System.Text.UTF8Encoding]::new($false))
    }
}

function Get-SpecrewManifestReleaseInfo {
    param([Parameter(Mandatory = $true)][string]$ManifestPath)

    $manifest = Import-PowerShellDataFile -Path $ManifestPath
    $prerelease = ''
    if (
        $manifest.ContainsKey('PrivateData') -and
        $manifest.PrivateData -and
        $manifest.PrivateData.ContainsKey('PSData') -and
        $manifest.PrivateData.PSData -and
        $manifest.PrivateData.PSData.ContainsKey('Prerelease') -and
        $null -ne $manifest.PrivateData.PSData['Prerelease']
    ) {
        $prerelease = [string]$manifest.PrivateData.PSData['Prerelease']
    }

    return [pscustomobject]@{
        ModuleVersion = [string]$manifest.ModuleVersion
        Prerelease    = $prerelease
    }
}

function ConvertTo-ManifestPrerelease {
    param([AllowEmptyString()][string]$TagPrerelease)

    if ([string]::IsNullOrWhiteSpace($TagPrerelease)) {
        return ''
    }

    return ($TagPrerelease -replace '[.+]', '')
}

function Resolve-ReleaseStamp {
    param(
        [Parameter(Mandatory = $true)][string]$ReleaseMode,
        [AllowEmptyString()][string]$GitRefType,
        [AllowEmptyString()][string]$GitRefName,
        [Parameter(Mandatory = $true)][string]$ExpectedVersion
    )

    if ([string]::IsNullOrWhiteSpace($GitRefType) -or [string]::IsNullOrWhiteSpace($GitRefName)) {
        if ($ReleaseMode -ne 'dry-run') {
            throw ("Release mode '{0}' requires a v*.* tag ref or workflow_dispatch release_tag input." -f $ReleaseMode)
        }

        return [pscustomobject]@{
            ModuleVersion       = $ExpectedVersion
            ManifestPrerelease  = ''
            SourcePrereleaseTag = ''
            EffectiveVersion    = $ExpectedVersion
        }
    }

    if ($GitRefType -ne 'tag') {
        if ($ReleaseMode -ne 'dry-run') {
            throw ("Release mode '{0}' requires a tag ref, but the workflow is running against ref type '{1}'." -f $ReleaseMode, $GitRefType)
        }

        return [pscustomobject]@{
            ModuleVersion       = $ExpectedVersion
            ManifestPrerelease  = ''
            SourcePrereleaseTag = ''
            EffectiveVersion    = $ExpectedVersion
        }
    }

    if ($GitRefName -notmatch '^v(?<version>\d+\.\d+\.\d+)(?:-(?<prerelease>[0-9A-Za-z][0-9A-Za-z.-]*))?$') {
        throw ("Tag '{0}' does not follow the required v*.* format." -f $GitRefName)
    }

    $tagVersion = $Matches.version
    $tagPrerelease = if ($Matches.ContainsKey('prerelease') -and -not [string]::IsNullOrWhiteSpace($Matches.prerelease)) { $Matches.prerelease } else { '' }
    if ($tagVersion -ne $ExpectedVersion) {
        throw ("Tag version '{0}' does not match .specrew/config.yml specrew_version '{1}'." -f $tagVersion, $ExpectedVersion)
    }

    $normalizedTagPrerelease = ConvertTo-ManifestPrerelease -TagPrerelease $tagPrerelease

    $manifestPrerelease = switch ($ReleaseMode) {
        'dry-run' { $normalizedTagPrerelease }
        'publish-prerelease' {
            if ([string]::IsNullOrWhiteSpace($tagPrerelease)) {
                throw ("Release mode '{0}' requires a prerelease tag like v{1}-beta.1." -f $ReleaseMode, $ExpectedVersion)
            }

            $normalizedTagPrerelease
        }
        'publish-stable' {
            if (-not [string]::IsNullOrWhiteSpace($tagPrerelease)) {
                throw ("Release mode '{0}' requires a stable tag with no prerelease suffix." -f $ReleaseMode)
            }

            ''
        }
        'promote-prerelease' {
            if ([string]::IsNullOrWhiteSpace($tagPrerelease)) {
                throw ("Release mode '{0}' requires a prerelease tag to promote from." -f $ReleaseMode)
            }

            ''
        }
        default {
            throw ("Unsupported release mode '{0}'." -f $ReleaseMode)
        }
    }

    $effectiveVersion = if ([string]::IsNullOrWhiteSpace($manifestPrerelease)) {
        $ExpectedVersion
    }
    else {
        '{0}-{1}' -f $ExpectedVersion, $manifestPrerelease
    }

    return [pscustomobject]@{
        ModuleVersion       = $ExpectedVersion
        ManifestPrerelease  = $manifestPrerelease
        SourcePrereleaseTag = $tagPrerelease
        EffectiveVersion    = $effectiveVersion
    }
}

function New-ReleaseScratchRoot {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')]
    param([Parameter(Mandatory = $true)][string]$RepositoryRoot)

    $scratchRoot = Join-Path -Path $RepositoryRoot -ChildPath '.scratch\module-release'
    if (Test-Path -LiteralPath $scratchRoot) {
        if ($PSCmdlet.ShouldProcess($scratchRoot, 'Reset release scratch root')) {
            Remove-Item -LiteralPath $scratchRoot -Recurse -Force
        }
    }

    if ($PSCmdlet.ShouldProcess($scratchRoot, 'Create release scratch root')) {
        $null = New-Item -Path $scratchRoot -ItemType Directory -Force
    }
    return $scratchRoot
}

function Copy-ReleaseFile {
    param(
        [Parameter(Mandatory = $true)][string]$RepositoryRoot,
        [Parameter(Mandatory = $true)][string]$StageRoot,
        [Parameter(Mandatory = $true)][string]$RelativePath
    )

    $sourcePath = Join-Path -Path $RepositoryRoot -ChildPath $RelativePath
    if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
        throw "Missing release file '$RelativePath'."
    }

    $destinationPath = Join-Path -Path $StageRoot -ChildPath $RelativePath
    $destinationDirectory = Split-Path -Path $destinationPath -Parent
    if (-not [string]::IsNullOrWhiteSpace($destinationDirectory) -and -not (Test-Path -LiteralPath $destinationDirectory)) {
        $null = New-Item -Path $destinationDirectory -ItemType Directory -Force
    }

    Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -Force
}

function New-ReleaseStageRoot {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')]
    param(
        [Parameter(Mandatory = $true)][string]$RepositoryRoot,
        [Parameter(Mandatory = $true)][string]$ScratchRoot,
        [Parameter(Mandatory = $true)][string]$ManifestPath
    )

    $stageRoot = Join-Path -Path $ScratchRoot -ChildPath 'Specrew'
    if ($PSCmdlet.ShouldProcess($stageRoot, 'Create staged module release root')) {
        $null = New-Item -Path $stageRoot -ItemType Directory -Force
    }

    $manifest = Import-PowerShellDataFile -Path $ManifestPath
    $filesToStage = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($relativePath in @($manifest.FileList)) {
        if ([string]::IsNullOrWhiteSpace($relativePath)) {
            continue
        }

        if ($filesToStage.Add($relativePath)) {
            Copy-ReleaseFile -RepositoryRoot $RepositoryRoot -StageRoot $stageRoot -RelativePath $relativePath
        }
    }

    foreach ($optionalPath in @('README.md', 'CHANGELOG.md', 'LICENSE', 'NOTICE.md')) {
        $sourcePath = Join-Path -Path $RepositoryRoot -ChildPath $optionalPath
        if ((Test-Path -LiteralPath $sourcePath -PathType Leaf) -and $filesToStage.Add($optionalPath)) {
            Copy-ReleaseFile -RepositoryRoot $RepositoryRoot -StageRoot $stageRoot -RelativePath $optionalPath
        }
    }

    return $stageRoot
}

function Get-ReleaseBuildId {
    param([Parameter(Mandatory = $true)][string]$RepositoryRoot)

    foreach ($candidate in @($env:SPECREW_BUILD_COMMIT, $env:GITHUB_SHA)) {
        $value = ([string]$candidate).Trim()
        if ($value -match '^[0-9a-fA-F]{7,40}$') {
            return $value.Substring(0, [Math]::Min(8, $value.Length)).ToLowerInvariant()
        }
    }

    if (Test-Path -LiteralPath (Join-Path $RepositoryRoot '.git')) {
        $head = (& git -C $RepositoryRoot rev-parse --short=8 HEAD 2>$null)
        if ($LASTEXITCODE -eq 0 -and ([string]$head).Trim() -match '^[0-9a-fA-F]{7,8}$') {
            return ([string]$head).Trim().ToLowerInvariant()
        }
    }

    throw 'Could not resolve the release build commit. Set SPECREW_BUILD_COMMIT or package from a git checkout.'
}

function Get-SpecrewPackageContentSha256 {
    # A hash of WHAT IS IN THE PACKAGE, computed the same way runtime_bundle_sha256 is: sorted relative
    # paths plus per-file content, so the order files happen to be enumerated in cannot change it.
    # build-stamp.json is excluded because it is about to contain this value.
    #
    # -RelativePaths SCOPES THE HASH TO A KNOWN FILE SET, which an INSTALL needs and a stage does not.
    # An install legitimately carries files no package contains - the module's own version-check cache
    # and PowerShellGet's provenance file - so hashing the whole install root compares a package against
    # a package plus two extras and always disagrees. The first version did exactly that, and its own
    # post-install check caught it: byte verification passed for all 410 files and the identity assertion
    # still failed. The defect was in the question, not the copy.
    param(
        [Parameter(Mandatory = $true)][string]$StageRoot,
        [string[]]$RelativePaths
    )

    $root = (Resolve-Path -LiteralPath $StageRoot -ErrorAction Stop).Path
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $accumulator = [System.IO.MemoryStream]::new()
        $scoped = $null
        if ($null -ne $RelativePaths -and @($RelativePaths).Count -gt 0) {
            $scoped = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
            foreach ($candidate in @($RelativePaths)) { [void]$scoped.Add(([string]$candidate -replace '\\', '/')) }
        }
        $files = @(Get-ChildItem -LiteralPath $root -File -Recurse -Force -ErrorAction Stop |
                Where-Object { $_.Name -cne 'build-stamp.json' } |
                Where-Object { $null -eq $scoped -or $scoped.Contains((([IO.Path]::GetRelativePath($root, $_.FullName)) -replace '\\', '/')) } |
                Sort-Object { ([IO.Path]::GetRelativePath($root, $_.FullName) -replace '\\', '/') })
        foreach ($file in $files) {
            $relative = ([IO.Path]::GetRelativePath($root, $file.FullName) -replace '\\', '/')
            $nameBytes = [Text.Encoding]::UTF8.GetBytes($relative + "`n")
            $accumulator.Write($nameBytes, 0, $nameBytes.Length)
            $fileHash = $sha.ComputeHash([IO.File]::ReadAllBytes($file.FullName))
            $accumulator.Write($fileHash, 0, $fileHash.Length)
        }
        $accumulator.Position = 0
        return ([BitConverter]::ToString($sha.ComputeHash($accumulator)) -replace '-', '').ToLowerInvariant()
    }
    finally { $sha.Dispose() }
}

function Write-ReleaseBuildStamp {
    # THE STAMP DESCRIBES THE PACKAGE, NOT JUST THE INTENT.
    #
    # It used to record only a commit id supplied at package time, so an install could claim 248dd0d2
    # while carrying entirely different code - which is exactly what happened on 2026-08-20, and it was
    # caught only because someone diffed 82 files by hand. `commit` stays, because provenance is worth
    # recording; `content_sha256` is added so the claim is CHECKABLE against the files themselves.
    param(
        [Parameter(Mandatory = $true)][string]$StageRoot,
        [Parameter(Mandatory = $true)][string]$Commit
    )

    $stampPath = Join-Path $StageRoot 'build-stamp.json'
    # content_file_count RECORDS THE SCOPE, because a hash without its scope is not reproducible. The
    # first independent verification of this stamp disagreed with it, and the install was fine - the
    # verifier had scoped to FileList while the stamp covers every staged file except itself, which also
    # includes the optional README/CHANGELOG/LICENSE/NOTICE. A verifier who counts a different number of
    # files now knows that before comparing hashes, instead of suspecting the package.
    $stampedFiles = @(Get-ChildItem -LiteralPath $StageRoot -File -Recurse -Force -ErrorAction Stop |
            Where-Object { $_.Name -cne 'build-stamp.json' })
    $content = [ordered]@{
        schema = 'specrew-build-stamp/v1'
        commit = $Commit
        content_sha256 = Get-SpecrewPackageContentSha256 -StageRoot $StageRoot
        content_file_count = $stampedFiles.Count
    } | ConvertTo-Json
    [System.IO.File]::WriteAllText($stampPath, ($content + [Environment]::NewLine), [System.Text.UTF8Encoding]::new($false))
    return $stampPath
}