[CmdletBinding()] param( [string]$InstanceUrl, [string]$Owner, [string]$Repo, [string]$RunnerName, [string]$RunnerRoot, [string]$ApiToken, [switch]$SkipInstall, [switch]$SkipStartupRegistration ) $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 { $remoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', 'origin') $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 origin remote URL: $normalized" } function Resolve-ApiToken { param( [Parameter(Mandatory = $true)] [string]$BaseUrl, [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]$BaseUrl $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)] [string]$Method, [Parameter(Mandatory = $true)] [string]$Uri, [Parameter(Mandatory = $true)] [string]$Token ) $headers = @{ Authorization = "token $Token" } try { return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $headers -ErrorAction Stop } 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 Resolve-RunnerExe { $command = Get-Command -Name 'gitea-runner.exe' -ErrorAction SilentlyContinue if ($null -ne $command) { return $command.Source } $command = Get-Command -Name 'gitea-runner' -ErrorAction SilentlyContinue if ($null -ne $command) { return $command.Source } $candidate = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\Gitea.Runner_Microsoft.Winget.Source_8wekyb3d8bbwe\gitea-runner.exe' if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } if ($SkipInstall) { throw 'gitea-runner was not found and -SkipInstall was requested.' } & winget install --id Gitea.Runner -e --accept-package-agreements --accept-source-agreements if ($LASTEXITCODE -ne 0) { throw "winget install Gitea.Runner failed with exit code $LASTEXITCODE" } if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { throw "gitea-runner was installed but not found at expected path: $candidate" } return $candidate } $context = Resolve-GiteaRepositoryContext if ([string]::IsNullOrWhiteSpace($InstanceUrl)) { $InstanceUrl = $context.BaseUrl } if ([string]::IsNullOrWhiteSpace($Owner)) { $Owner = $context.Owner } if ([string]::IsNullOrWhiteSpace($Repo)) { $Repo = $context.Repo } if ([string]::IsNullOrWhiteSpace($RunnerName)) { $RunnerName = "$Repo-local-win" } if ([string]::IsNullOrWhiteSpace($RunnerRoot)) { $RunnerRoot = Join-Path $env:LOCALAPPDATA "GiteaRunner\$Repo" } $runnerExe = Resolve-RunnerExe $ApiToken = Resolve-ApiToken -BaseUrl $InstanceUrl -RepoOwner $Owner -RepoName $Repo -ExplicitToken $ApiToken $registration = Invoke-GiteaApi -Method 'POST' -Uri "$InstanceUrl/api/v1/repos/$Owner/$Repo/actions/runners/registration-token" -Token $ApiToken $registrationToken = [string]$registration.token if ([string]::IsNullOrWhiteSpace($registrationToken)) { throw 'Gitea did not return a runner registration token.' } $configPath = Join-Path $RunnerRoot 'config.yaml' $registrationPath = Join-Path $RunnerRoot '.runner' $logPath = Join-Path $RunnerRoot 'daemon.log' $startScriptPath = Join-Path $RunnerRoot 'start-runner.ps1' $startupFolder = [Environment]::GetFolderPath('Startup') $startupCmdPath = Join-Path $startupFolder "$Repo-gitea-runner.cmd" $workdirParent = Join-Path $RunnerRoot 'workdir' New-Item -ItemType Directory -Force -Path $RunnerRoot, $workdirParent | Out-Null $fullPath = [string]::Join(';', (@( [Environment]::GetEnvironmentVariable('Path', 'User'), [Environment]::GetEnvironmentVariable('Path', 'Machine') ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) $cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $HOME '.cargo' } $rustupHome = if ($env:RUSTUP_HOME) { $env:RUSTUP_HOME } else { Join-Path $HOME '.rustup' } $repoHostLabel = "${Repo}:host" $config = @" log: level: info runner: file: .runner capacity: 1 envs: PATH: $fullPath CARGO_HOME: $cargoHome RUSTUP_HOME: $rustupHome CARGO_INCREMENTAL: '0' env_file: .env timeout: 3h shutdown_timeout: 30s insecure: false fetch_timeout: 5s fetch_interval: 2s fetch_interval_max: 5s workdir_cleanup_age: 24h idle_cleanup_interval: 10m labels: - 'windows-amd64:host' - 'windows:host' - '$repoHostLabel' allocate_pty: false cache: enabled: true dir: '' host: '' port: 0 external_server: '' external_secret: '' offline_mode: false container: network: '' privileged: false options: workdir_parent: valid_volumes: [] docker_host: '' force_pull: true force_rebuild: false require_docker: false docker_timeout: 0s bind_workdir: false host: workdir_parent: '$($workdirParent -replace '\\', '/')' metrics: enabled: false addr: '127.0.0.1:9101' "@ Set-Content -LiteralPath $configPath -Value $config -Encoding utf8NoBOM if (-not (Test-Path -LiteralPath $registrationPath)) { Push-Location -LiteralPath $RunnerRoot try { & $runnerExe register --no-interactive --config $configPath --instance $InstanceUrl --token $registrationToken --name $RunnerName if ($LASTEXITCODE -ne 0) { throw "gitea-runner register failed with exit code $LASTEXITCODE" } } finally { Pop-Location } } $startScript = @" `$ErrorActionPreference = 'Stop' Set-Location '$RunnerRoot' & '$runnerExe' daemon --config '$configPath' *>> '$logPath' "@ Set-Content -LiteralPath $startScriptPath -Value $startScript -Encoding utf8NoBOM if (-not $SkipStartupRegistration) { $startupCmd = @" @echo off powershell -NoProfile -WindowStyle Hidden -File "$startScriptPath" "@ Set-Content -LiteralPath $startupCmdPath -Value $startupCmd -Encoding ascii } $existing = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'gitea-runner.exe' -and $_.CommandLine -like "*$configPath*" } if ($null -eq $existing) { Start-Process -FilePath $runnerExe -ArgumentList @('daemon', '--config', $configPath) -WorkingDirectory $RunnerRoot -WindowStyle Hidden | Out-Null Start-Sleep -Seconds 3 } $runners = Invoke-GiteaApi -Method 'GET' -Uri "$InstanceUrl/api/v1/repos/$Owner/$Repo/actions/runners" -Token $ApiToken $matched = @($runners.runners | Where-Object { $_.name -eq $RunnerName }) if ($matched.Count -eq 0) { throw "Runner $RunnerName was not visible in the repository runner list after setup." } $matched | ConvertTo-Json -Depth 6