Files

366 lines
12 KiB
PowerShell

[CmdletBinding()]
param(
[string]$Remote = 'origin',
[string]$BaseUrl,
[string]$Owner,
[string]$Repo,
[string]$ApiToken,
[string]$Ref = 'main',
[string]$WorkflowId = 'ci.yml',
[string]$RequiredLabel,
[switch]$DispatchIfMissing,
[switch]$Wait,
[ValidateRange(30, 21600)]
[int]$TimeoutSeconds = 1800,
[ValidateRange(5, 300)]
[int]$PollSeconds = 15
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Invoke-GitCapture {
param(
[Parameter(Mandatory = $true)]
[string[]]$ArgumentList
)
$output = & git @ArgumentList 2>&1
if ($LASTEXITCODE -ne 0) {
throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")"
}
return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim()
}
function Resolve-GiteaRepositoryContext {
param(
[string]$RemoteName,
[string]$RemoteUrl
)
if ([string]::IsNullOrWhiteSpace($RemoteUrl)) {
$RemoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', $RemoteName)
}
$normalized = $RemoteUrl.Trim()
if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') {
return [pscustomobject]@{
BaseUrl = $Matches[1]
Owner = $Matches[2]
Repo = $Matches[3]
}
}
if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') {
$giteaHost = $Matches[1]
$port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" }
return [pscustomobject]@{
BaseUrl = "https://$giteaHost$port"
Owner = $Matches[3]
Repo = $Matches[4]
}
}
throw "Could not parse Gitea remote URL: $normalized"
}
function Resolve-ApiToken {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedBaseUrl,
[Parameter(Mandatory = $true)]
[string]$RepoOwner,
[Parameter(Mandatory = $true)]
[string]$RepoName,
[string]$ExplicitToken
)
if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) {
return $ExplicitToken
}
foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) {
$value = [Environment]::GetEnvironmentVariable($name)
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value
}
}
$uri = [Uri]$ResolvedBaseUrl
$lines = @(
'protocol=https'
"host=$($uri.Authority)"
"path=$RepoOwner/$RepoName.git"
''
) | git credential fill
$passwordLine = $lines | Where-Object { $_ -like 'password=*' } | Select-Object -First 1
if ($null -eq $passwordLine) {
throw 'Could not resolve a Gitea API token from the current environment or git credential manager.'
}
return $passwordLine.Substring(9)
}
function Invoke-GiteaApi {
param(
[Parameter(Mandatory = $true)]
[ValidateSet('GET', 'POST')]
[string]$Method,
[Parameter(Mandatory = $true)]
[string]$Uri,
[Parameter(Mandatory = $true)]
[string]$Token,
[object]$Body
)
$invokeArgs = @{
Method = $Method
Uri = $Uri
Headers = @{ Authorization = "token $Token" }
ErrorAction = 'Stop'
}
if ($PSBoundParameters.ContainsKey('Body')) {
$invokeArgs.ContentType = 'application/json'
$invokeArgs.Body = $Body | ConvertTo-Json -Depth 8
}
try {
return Invoke-RestMethod @invokeArgs
}
catch {
$response = $_.Exception.Response
if ($null -eq $response) {
throw
}
$statusCode = [int]$response.StatusCode
$payload = ''
if ($response -is [System.Net.Http.HttpResponseMessage]) {
if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) {
$payload = $_.ErrorDetails.Message
}
elseif ($null -ne $response.Content) {
try {
$payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
catch {
$payload = ''
}
}
}
elseif ($response.PSObject.Methods.Name -contains 'GetResponseStream') {
$stream = $response.GetResponseStream()
if ($null -ne $stream) {
$reader = [System.IO.StreamReader]::new($stream)
try {
$payload = $reader.ReadToEnd()
}
finally {
$reader.Dispose()
$stream.Dispose()
}
}
}
throw "Gitea API $Method $Uri failed with HTTP ${statusCode}: $payload"
}
}
function Get-OptionalProperty {
param(
[Parameter(Mandatory = $true)]
[object]$InputObject,
[Parameter(Mandatory = $true)]
[string]$Name
)
$property = $InputObject.PSObject.Properties[$Name]
if ($null -eq $property) {
return $null
}
return $property.Value
}
function Resolve-WorkflowRunnerLabel {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot,
[Parameter(Mandatory = $true)]
[string]$WorkflowFileName,
[string]$ExplicitLabel
)
if (-not [string]::IsNullOrWhiteSpace($ExplicitLabel)) {
return $ExplicitLabel
}
$workflowPath = Join-Path $WorkspaceRoot (Join-Path '.gitea\workflows' $WorkflowFileName)
if (-not (Test-Path -LiteralPath $workflowPath -PathType Leaf)) {
throw "Workflow file not found: $workflowPath"
}
$content = Get-Content -Raw -LiteralPath $workflowPath
$match = [regex]::Match($content, '(?m)^\s*runs-on:\s*[''"]?([^''"\r\n\[\], ]+)')
if (-not $match.Success) {
throw "Could not resolve runs-on label from $workflowPath"
}
return $match.Groups[1].Value
}
function Find-HeadRun {
param(
[Parameter(Mandatory = $true)]
[object]$Runs,
[Parameter(Mandatory = $true)]
[string]$HeadSha,
[Parameter(Mandatory = $true)]
[string]$ExpectedWorkflowId
)
$matching = @(
$Runs.workflow_runs |
Where-Object { $_.head_sha -eq $HeadSha } |
Where-Object {
$workflowId = ''
$path = ''
if ($_.PSObject.Properties.Name -contains 'workflow_id') {
$workflowId = [string]$_.workflow_id
}
if ($_.PSObject.Properties.Name -contains 'path') {
$path = [string]$_.path
}
($workflowId -eq $ExpectedWorkflowId) -or
($path -like "$ExpectedWorkflowId@*") -or
[string]::IsNullOrWhiteSpace($workflowId)
} |
Sort-Object id -Descending
)
if ($matching.Count -eq 0) {
return $null
}
return $matching[0]
}
$workspaceRoot = Split-Path -Parent $PSScriptRoot
Push-Location -LiteralPath $workspaceRoot
try {
$context = Resolve-GiteaRepositoryContext -RemoteName $Remote
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
$BaseUrl = $context.BaseUrl
}
if ([string]::IsNullOrWhiteSpace($Owner)) {
$Owner = $context.Owner
}
if ([string]::IsNullOrWhiteSpace($Repo)) {
$Repo = $context.Repo
}
$ApiToken = Resolve-ApiToken -ResolvedBaseUrl $BaseUrl -RepoOwner $Owner -RepoName $Repo -ExplicitToken $ApiToken
$requiredRunnerLabel = Resolve-WorkflowRunnerLabel -WorkspaceRoot $workspaceRoot -WorkflowFileName $WorkflowId -ExplicitLabel $RequiredLabel
$apiBase = "$BaseUrl/api/v1/repos/$Owner/$Repo"
$workflow = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/workflows/$WorkflowId" -Token $ApiToken
if ($workflow.state -ne 'active') {
throw "Workflow $WorkflowId is not active; current state is $($workflow.state)."
}
$branch = Invoke-GiteaApi -Method GET -Uri "$apiBase/branches/$Ref" -Token $ApiToken
$headSha = [string]$branch.commit.id
if ([string]::IsNullOrWhiteSpace($headSha)) {
throw "Could not resolve head SHA for ref $Ref."
}
$runners = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runners" -Token $ApiToken
$matchingRunners = @(
$runners.runners |
Where-Object { $_.status -eq 'online' } |
Where-Object {
$labelNames = @($_.labels | ForEach-Object { $_.name })
$labelNames -contains $requiredRunnerLabel
}
)
if ($matchingRunners.Count -eq 0) {
throw "No online Gitea runner exposes required label '$requiredRunnerLabel'."
}
$runs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs?limit=30" -Token $ApiToken
$run = Find-HeadRun -Runs $runs -HeadSha $headSha -ExpectedWorkflowId $WorkflowId
$dispatched = $false
if ($null -eq $run -and $DispatchIfMissing) {
Invoke-GiteaApi -Method POST -Uri "$apiBase/actions/workflows/$WorkflowId/dispatches" -Token $ApiToken -Body @{ ref = $Ref } | Out-Null
$dispatched = $true
}
elseif ($null -eq $run) {
throw "No Gitea Actions run found for $WorkflowId at $Ref ($headSha). Pass -DispatchIfMissing to trigger one."
}
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds)
while ($Wait -and ($null -eq $run -or $run.status -ne 'completed')) {
if ([DateTimeOffset]::UtcNow -ge $deadline) {
$status = if ($null -eq $run) { 'missing' } else { [string]$run.status }
throw "Timed out waiting for $WorkflowId at $headSha; last status was $status."
}
Start-Sleep -Seconds $PollSeconds
$runs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs?limit=30" -Token $ApiToken
$run = Find-HeadRun -Runs $runs -HeadSha $headSha -ExpectedWorkflowId $WorkflowId
}
if ($null -eq $run) {
throw "No Gitea Actions run found for $WorkflowId at $Ref ($headSha)."
}
$jobs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs/$($run.id)/jobs" -Token $ApiToken
$jobSummaries = @(
$jobs.jobs | ForEach-Object {
[pscustomobject]@{
id = $_.id
name = $_.name
status = $_.status
conclusion = Get-OptionalProperty -InputObject $_ -Name 'conclusion'
runner_name = Get-OptionalProperty -InputObject $_ -Name 'runner_name'
labels = @($_.labels)
}
}
)
$runConclusion = Get-OptionalProperty -InputObject $run -Name 'conclusion'
if ($run.status -eq 'completed' -and $runConclusion -ne 'success') {
throw "Gitea Actions run $($run.id) completed with conclusion '$runConclusion'."
}
[pscustomobject]@{
ok = ($run.status -eq 'completed' -and $runConclusion -eq 'success')
base_url = $BaseUrl
owner = $Owner
repo = $Repo
ref = $Ref
head_sha = $headSha
workflow_id = $WorkflowId
workflow_state = $workflow.state
required_runner_label = $requiredRunnerLabel
online_runner_count = $matchingRunners.Count
dispatched = $dispatched
run = [pscustomobject]@{
id = $run.id
run_number = $run.run_number
event = $run.event
status = $run.status
conclusion = $runConclusion
head_sha = $run.head_sha
head_branch = $run.head_branch
display_title = $run.display_title
}
jobs = $jobSummaries
} | ConvertTo-Json -Depth 8
}
finally {
Pop-Location
}