# Latchpoint — Perforce change-commit trigger (Windows Helix Core servers) # # Install: # 1. Copy to the Perforce server, e.g. C:\p4\latchpoint-trigger.ps1 # 2. Set for the account running p4d: # LATCHPOINT_URL= # LATCHPOINT_SECRET= # LATCHPOINT_SWARM_URL= # 3. p4 triggers # latchpoint change-commit //... "powershell -NoProfile -ExecutionPolicy Bypass -File C:\p4\latchpoint-trigger.ps1 %change% %user%" # # -ExecutionPolicy Bypass is REQUIRED, not defensive. Windows Server defaults to # RemoteSigned, which refuses to run any script downloaded from the internet — this # one included. Without it PowerShell exits 1 before the script starts, and Perforce # reads that as a failed trigger. Admins who prefer not to pass Bypass can instead run # `Unblock-File C:\p4\latchpoint-trigger.ps1` once, after reviewing the source. # # Design rule: this script MUST NOT be able to block or delay a submit. Every path # exits 0, nothing prompts, and no unhandled error can escape. A version-control # server is not a place to be clever. # # Runs under Windows PowerShell 5.1 (what `powershell -File` invokes) and PowerShell 7+. # NOT Mandatory: a mandatory parameter with an empty value prompts for input, and a # trigger has no console — it would hang until p4d's timeout instead of failing fast. param( [string]$Change = '', [string]$User = '' ) # 'Stop' is actively dangerous here. Under Windows PowerShell 5.1, a native command # writing ANY stderr while its output is redirected raises NativeCommandError; with # 'Stop' that terminates the script with exit code 1 and the submit is reported failed. $ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false $ProgressPreference = 'SilentlyContinue' function Get-Payload { param([string]$Change, [string]$User) $url = $env:LATCHPOINT_URL $secret = $env:LATCHPOINT_SECRET $p4 = if ($env:P4_EXE) { $env:P4_EXE } else { 'p4' } $depot = if ($env:LATCHPOINT_DEPOT) { $env:LATCHPOINT_DEPOT } else { 'depot' } $swarm = $env:LATCHPOINT_SWARM_URL if (-not $Change) { return @{ Skip = 'no changelist number supplied' } } if (-not $url) { return @{ Skip = 'LATCHPOINT_URL not set' } } if (-not $secret) { return @{ Skip = 'LATCHPOINT_SECRET not set' } } # 2>$null discards stderr rather than merging it into the output stream, which is # what makes NativeCommandError possible in the first place. $raw = & $p4 -ztag describe -s $Change 2>$null if ($LASTEXITCODE -ne 0) { return @{ Skip = 'p4 describe failed' } } $fields = @{} $fileCount = 0 foreach ($line in $raw) { if ("$line" -match '^\.\.\.\s+(\S+)\s*(.*)$') { $k = $matches[1]; $v = $matches[2] if ($k -match '^depotFile') { $fileCount++ } elseif (-not $fields.ContainsKey($k)) { $fields[$k] = $v } } } $desc = $fields['desc'] if (-not $desc) { return @{ Skip = 'empty description' } } # Cheap local guard: no Jira key, no network call. if ($desc -notmatch '\b[A-Z][A-Z0-9]+-\d+\b') { return @{ Skip = 'no Jira issue key in description' } } $epoch = 0 [void][int]::TryParse("$($fields['time'])", [ref]$epoch) $iso = if ($epoch -gt 0) { [DateTimeOffset]::FromUnixTimeSeconds($epoch).UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } # File PATHS are deliberately not sent — only how many. See latchpoint.app/perforce. $body = @{ depot = $depot changelists = @(@{ change = if ($fields['change']) { $fields['change'] } else { $Change } description = $desc user = if ($User) { $User } else { $fields['user'] } time = $iso fileCount = $fileCount }) } if ($swarm) { $body['serverUrl'] = $swarm.TrimEnd('/') } return @{ Url = $url; Secret = $secret; Json = ($body | ConvertTo-Json -Depth 6 -Compress) } } function Publish { param([string]$Change, [string]$User) $p = Get-Payload -Change $Change -User $User if ($p.Skip) { return "Latchpoint: $($p.Skip); skipping." } # Windows Server images often still negotiate TLS 1.0 by default, which Atlassian rejects. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } # Encode explicitly so non-ASCII changelist descriptions survive the round trip. $bytes = [Text.Encoding]::UTF8.GetBytes($p.Json) try { $resp = Invoke-WebRequest -Uri $p.Url -Method Post -Body $bytes ` -ContentType 'application/json; charset=utf-8' ` -Headers @{ 'X-Latchpoint-Secret' = $p.Secret } ` -TimeoutSec 20 -UseBasicParsing } catch { $code = try { [int]$_.Exception.Response.StatusCode } catch { 0 } $why = if ($code) { "HTTP $code" } else { $_.Exception.Message } return "Latchpoint: publish failed ($why); submit unaffected." } if ($resp.StatusCode -ne 200) { return "Latchpoint: publish failed (HTTP $($resp.StatusCode)); submit unaffected." } $published = try { (ConvertFrom-Json $resp.Content).published } catch { $null } if ($null -ne $published -and $published -eq 0) { return "Latchpoint: changelist $Change accepted, nothing published (no matching Jira issue)." } return "Latchpoint: published changelist $Change." } try { $message = Publish -Change $Change -User $User } catch { $message = "Latchpoint: unexpected error ($($_.Exception.Message)); submit unaffected." } Write-Output $message exit 0