Public/Remove-StaleContainerImages.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Remove-StaleContainerImages {
    <#
    .SYNOPSIS
        Deletes untagged GHCR container image versions older than a cutoff, keeping N most recent.
    .DESCRIPTION
        Consolidates the paginate→filter→delete pattern hand-rolled in
        `.github/workflows/ghcr-cleanup.yml` (t/1492). Paginates the GitHub
        Packages API for a container package, filters to untagged versions
        older than the cutoff, and deletes all but the N most recent.
 
        Supports -WhatIf and -Confirm — the delete step is guarded by
        ShouldProcess, so `-WhatIf` lists exactly what would be deleted
        without touching the registry.
 
        Returns a StaleImageCleanupResult with kept/deleted counts, the
        list of deleted version IDs, and any per-version failure messages.
    .PARAMETER Owner
        GitHub owner (user or org) that owns the package. Defaults to the
        GHCR_OWNER env var, then GITHUB_REPOSITORY_OWNER, then 'jpsnover'.
    .PARAMETER Package
        Container package name (e.g. 'taxonomy-editor').
    .PARAMETER OlderThanDays
        Only delete versions whose updated_at is older than this many days.
        Default: 30.
    .PARAMETER KeepLatest
        Always keep the N most recently updated untagged versions,
        regardless of age. Default: 5.
    .PARAMETER MaxPages
        Safety cap on pagination. Default: 10 (up to 1000 versions).
    .EXAMPLE
        Remove-StaleContainerImages -Package taxonomy-editor -WhatIf
    .EXAMPLE
        Remove-StaleContainerImages -Package ai-triad-base -KeepLatest 10 -OlderThanDays 14
    .LINK
        Show-AITriadHelp
    .LINK
        Test-AzureHealth
    .LINK
        Get-ContainerAppRevision
    .LINK
        Get-AzureFlightRecorder
    .LINK
        Test-TaxEditorInfra
    .LINK
        Deploy-TaxEditorInfra
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter()]
        [string]$Owner,

        [Parameter(Mandatory)]
        [string]$Package,

        [Parameter()]
        [ValidateRange(0, 3650)]
        [int]$OlderThanDays = 30,

        [Parameter()]
        [ValidateRange(0, 100)]
        [int]$KeepLatest = 5,

        [Parameter()]
        [ValidateRange(1, 50)]
        [int]$MaxPages = 10
    )

    Set-StrictMode -Version Latest

    if (-not $Owner) {
        $Owner = if ($env:GHCR_OWNER) { $env:GHCR_OWNER }
                 elseif ($env:GITHUB_REPOSITORY_OWNER) { $env:GITHUB_REPOSITORY_OWNER }
                 else { 'jpsnover' }
    }

    $Cutoff = (Get-Date).ToUniversalTime().AddDays(-$OlderThanDays)
    Write-Verbose "Owner=$Owner Package=$Package OlderThanDays=$OlderThanDays KeepLatest=$KeepLatest Cutoff=$($Cutoff.ToString('o'))"

    # ── Paginate versions ────────────────────────────────────────────────
    $AllVersions = [System.Collections.Generic.List[object]]::new()
    for ($Page = 1; $Page -le $MaxPages; $Page++) {
        $Endpoint = "/users/$Owner/packages/container/$Package/versions?per_page=100&page=$Page"
        $Batch = $null
        try {
            $Batch = Invoke-GitHubApi -Endpoint $Endpoint -Method 'GET' -CallerName 'Remove-StaleContainerImages'
        } catch {
            # Missing package → nothing to do (matches workflow's early-exit behavior).
            if ($_.Exception.Message -match 'Resource not found') {
                Write-Verbose "Package '$Package' not found under owner '$Owner' — nothing to clean up"
                $Result = [StaleImageCleanupResult]::new()
                $Result.Package         = $Package
                $Result.Owner           = $Owner
                $Result.TotalUntagged   = 0
                $Result.KeptCount       = 0
                $Result.DeletedCount    = 0
                $Result.DeletedIds      = @()
                $Result.Failures        = @()
                $Result.CutoffUtc       = $Cutoff.ToString('o')
                return $Result
            }
            throw
        }
        if (-not $Batch) { break }
        $BatchArray = @($Batch)
        foreach ($v in $BatchArray) { $AllVersions.Add($v) }
        if ($BatchArray.Count -lt 100) { break }  # last page
    }

    # ── Filter to untagged, sort newest first ────────────────────────────
    $Untagged = @(
        $AllVersions | Where-Object {
            $_.PSObject.Properties['metadata'] -and
            $_.metadata.PSObject.Properties['container'] -and
            $_.metadata.container.PSObject.Properties['tags'] -and
            @($_.metadata.container.tags).Count -eq 0
        } | Sort-Object -Property updated_at -Descending
    )

    $TotalUntagged = $Untagged.Count
    Write-Verbose "Untagged versions found: $TotalUntagged"

    # ── Compute delete set ───────────────────────────────────────────────
    $ToDelete = @()
    if ($TotalUntagged -gt $KeepLatest) {
        $Candidates = $Untagged | Select-Object -Skip $KeepLatest
        $ToDelete = @(
            $Candidates | Where-Object {
                ([datetime]$_.updated_at).ToUniversalTime() -lt $Cutoff
            }
        )
    }

    Write-Verbose "Versions to delete: $($ToDelete.Count)"

    # ── Delete (guarded by ShouldProcess) ────────────────────────────────
    $DeletedIds = [System.Collections.Generic.List[long]]::new()
    $Failures   = [System.Collections.Generic.List[string]]::new()
    foreach ($v in $ToDelete) {
        $Target = "version $($v.id) (updated $($v.updated_at))"
        if ($PSCmdlet.ShouldProcess($Target, "Delete untagged $Package")) {
            try {
                $null = Invoke-GitHubApi `
                    -Endpoint "/users/$Owner/packages/container/$Package/versions/$($v.id)" `
                    -Method DELETE `
                    -CallerName 'Remove-StaleContainerImages'
                $DeletedIds.Add([long]$v.id)
            } catch {
                $Failures.Add("$($v.id): $($_.Exception.Message)")
                Write-Warning "Failed to delete version $($v.id): $($_.Exception.Message)"
            }
        }
    }

    $Result = [StaleImageCleanupResult]::new()
    $Result.Package         = $Package
    $Result.Owner           = $Owner
    $Result.TotalUntagged   = $TotalUntagged
    $Result.KeptCount       = [Math]::Min($TotalUntagged, $KeepLatest)
    $Result.DeletedCount    = $DeletedIds.Count
    $Result.DeletedIds      = @($DeletedIds)
    $Result.Failures        = @($Failures)
    $Result.CutoffUtc       = $Cutoff.ToString('o')
    $Result
}