name: Forge Announce # Triggered when a vX.Y.Z tag is pushed. Reads .github/forge-posts/.md # (Frontmatter + DE bullet body) and the matching English block from # HellionChat/HellionChat.yaml, builds a Discord-Webhook embed and posts # it to the Hellion Forge #changelog channel. # # Decoupled from release.yml: a fail here does not block the GitHub # release, and a fail there does not block the announce. Spec lives in # the Vault under "Hellion Chat Forge-Auto-Announce Spec". # # Security: the only user-controlled inputs that enter run-steps are the # tag name and the frontmatter values from a repo-internal markdown file. # Tag name is read via env: (TAG_NAME, $env:TAG_NAME) and validated against # ^v\d+\.\d+\.\d+$ before any string interpolation. Frontmatter values are # parsed by regex with explicit length caps. No webhook event payload data # (issue titles, PR bodies, commit messages, etc.) flows into run-steps. on: push: tags: - 'v*' workflow_dispatch: inputs: tag: description: 'Existing tag to (re)post, e.g. v1.1.0' required: true type: string permissions: contents: read jobs: announce: name: Post changelog to Hellion Forge runs-on: ubuntu-latest # The DISCORD_FORGE_WEBHOOK secret is set as a repo-level Actions Secret # on Gitea (Settings → Actions → Secrets). Repo-level secrets are in # scope for every job by default, no environment: declaration needed. timeout-minutes: 5 steps: # On push:tags github.ref points at the tag commit; on workflow_dispatch # the user supplies the tag explicitly. Always check out that tag so # the yaml + forge-posts file are read from the tagged tree, not main. - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.inputs.tag || github.ref }} # Build embed-payload as a JSON file on disk. PowerShell-Core (pwsh) # ships pre-installed on ubuntu-latest so we get the same scripting # patterns release.yml uses on windows-latest. Tag is read via env: to # treat it as a string variable rather than inline shell text, and # validated against the semver regex before any interpolation. - name: Build embed payload id: build shell: pwsh env: TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }} run: | $tag = $env:TAG_NAME if ($tag -notmatch '^v\d+\.\d+\.\d+$') { throw "V1: Refusing to announce non-semver tag: $tag" } $version = $tag.Substring(1) # ---------- Forge-Post-Datei lesen ---------- $forgePath = ".github/forge-posts/$tag.md" if (-not (Test-Path $forgePath)) { throw "V2: Forge-Post-Datei für $tag fehlt unter .github/forge-posts/. Datei vor dem Tag anlegen, dann Tag re-pushen oder workflow_dispatch." } $forgeRaw = Get-Content -Path $forgePath -Raw # Frontmatter (--- … ---) am Datei-Anfang if ($forgeRaw -notmatch '(?s)\A---\s*\r?\n(.*?)\r?\n---\s*\r?\n(.*)\z') { throw "V3: Frontmatter (---) fehlt oder ist defekt in $forgePath" } $fmText = $matches[1] $deBody = $matches[2].Trim() $subtitle = $null $versionsnatur = $null foreach ($line in ($fmText -split "`r?`n")) { if ($line -match '^subtitle:\s*"?([^"]*)"?\s*$') { $subtitle = $matches[1] } if ($line -match '^versionsnatur:\s*"?([^"]*)"?\s*$') { $versionsnatur = $matches[1] } } if ([string]::IsNullOrWhiteSpace($subtitle)) { throw "V3: Frontmatter-Feld 'subtitle' fehlt in $forgePath" } if ([string]::IsNullOrWhiteSpace($versionsnatur)) { throw "V3: Frontmatter-Feld 'versionsnatur' fehlt in $forgePath" } if ($subtitle.Length -gt 60) { throw "V4: Frontmatter-Feld 'subtitle' überschreitet Limit ($($subtitle.Length) Char, max 60)" } if ($versionsnatur.Length -gt 40) { throw "V4: Frontmatter-Feld 'versionsnatur' überschreitet Limit ($($versionsnatur.Length) Char, max 40)" } if ([string]::IsNullOrWhiteSpace($deBody)) { throw "V3: DE-Body fehlt in $forgePath" } # ---------- EN-Block aus HellionChat.yaml ziehen ---------- # 1:1 Pattern aus release.yml — gleicher Header-Marker, gleiches # Trailer-Verhalten. Bei Drift die zwei Workflows synchron halten. $yamlPath = "HellionChat/HellionChat.yaml" $raw = Get-Content -Path $yamlPath -Raw $marker = "changelog: |-" $idx = $raw.IndexOf($marker) if ($idx -lt 0) { throw "V5: changelog-Block nicht gefunden in $yamlPath" } $afterMarker = $raw.Substring($idx + $marker.Length) $changelogBody = (($afterMarker -split "`r?`n") | ForEach-Object { if ($_ -match '^ ') { $_.Substring(2) } else { $_ } }) -join "`n" $header = "**Hellion Chat $version" $start = $changelogBody.IndexOf($header) if ($start -lt 0) { throw "V5: No changelog entry for version $version found in $yamlPath. Update the changelog block before tagging." } $rest = $changelogBody.Substring($start) $nextHdr = $rest.IndexOf("`n`n**Hellion Chat ", 1) $trailer = $rest.IndexOf("`n`n---") if ($nextHdr -ge 0 -and ($trailer -lt 0 -or $nextHdr -lt $trailer)) { $enBlock = $rest.Substring(0, $nextHdr).TrimEnd() } elseif ($trailer -ge 0) { $enBlock = $rest.Substring(0, $trailer).TrimEnd() } else { $enBlock = $rest.TrimEnd() } # ---------- Char-Cap-Check (5500 Total auf title + description + footer) ---------- $title = "Hellion Chat $version — $subtitle" $description = "**Deutsch**`n`n$deBody`n`n**English**`n`n$enBlock" $footerText = "Hellion Forge · $versionsnatur" $totalChars = $title.Length + $description.Length + $footerText.Length if ($totalChars -gt 5500) { throw "V6: Total char count $totalChars exceeds 5500 limit. Major-Release detected — please post manually via Bot/Multi-Embed (see forge style §8). Forge-Auto-Announce stays off for this tag." } Write-Host "Char-Cap OK: $totalChars / 5500" # ---------- Embed-Payload bauen ---------- $payload = [ordered]@{ username = "Forge Herald" avatar_url = "https://gitea.com/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png" content = "<@&1500489631555260446>" allowed_mentions = [ordered]@{ parse = @() roles = @("1500489631555260446") } embeds = @( [ordered]@{ title = $title url = "https://gitea.com/JonKazama-Hellion/HellionChat/releases/tag/$tag" color = 12730636 description = $description footer = [ordered]@{ text = $footerText } timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") } ) } $payloadJson = $payload | ConvertTo-Json -Depth 8 -Compress # Ausgabe-Datei ohne trailing newline für sauberes curl --data-binary @- [System.IO.File]::WriteAllText("$PWD/embed-payload.json", $payloadJson, [System.Text.UTF8Encoding]::new($false)) Write-Host "Payload size: $($payloadJson.Length) chars" Write-Host "Embed title: $title" Write-Host "Embed footer: $footerText" # POST to the Hellion Forge changelog webhook. curl from PowerShell-Core # so we can pipe the payload via stdin (--data-binary @-) and keep # secrets out of process arg lists. One retry on 5xx, hard fail on 4xx. - name: POST to Hellion Forge webhook shell: pwsh env: DISCORD_FORGE_WEBHOOK: ${{ secrets.DISCORD_FORGE_WEBHOOK }} run: | if ([string]::IsNullOrEmpty($env:DISCORD_FORGE_WEBHOOK)) { throw "V7: DISCORD_FORGE_WEBHOOK secret is empty. Check Settings → Environments → Webhook." } $payloadFile = "$PWD/embed-payload.json" if (-not (Test-Path $payloadFile)) { throw "Embed payload file missing — previous step did not produce embed-payload.json" } $maxAttempts = 2 $attempt = 0 while ($attempt -lt $maxAttempts) { $attempt++ Write-Host "POST attempt $attempt of $maxAttempts" $tmpResp = "$PWD/.webhook-response" $tmpHeaders = "$PWD/.webhook-headers" # --silent suppresses progress; --show-error prints errors so # the workflow log shows what happened. -w prints HTTP status # to stdout for inspection. -o captures body for diagnosis, # -D captures headers. $rawStatus = Get-Content $payloadFile -Raw | curl --silent --show-error ` --header 'Content-Type: application/json' ` --data-binary '@-' ` -D $tmpHeaders ` -o $tmpResp ` -w '%{http_code}' ` "$env:DISCORD_FORGE_WEBHOOK" $status = [int]$rawStatus Write-Host "HTTP status: $status" if ($status -ge 200 -and $status -lt 300) { Write-Host "Forge announce POST succeeded." exit 0 } $bodySnippet = "" if (Test-Path $tmpResp) { $bodySnippet = (Get-Content $tmpResp -Raw -ErrorAction SilentlyContinue) if ($bodySnippet.Length -gt 500) { $bodySnippet = $bodySnippet.Substring(0, 500) + " …" } } if ($status -ge 400 -and $status -lt 500) { # E2: 4xx is permanent — webhook revoked, channel deleted, # payload malformed. No retry. throw "E2: Discord-Webhook returned permanent $status. Body: $bodySnippet" } # E1: 5xx (or transport-level fail with status 0) — wait + retry once if ($attempt -lt $maxAttempts) { Write-Host "Transient $status — sleeping 30s before retry." Start-Sleep -Seconds 30 } else { throw "E1: Discord-Webhook returned transient $status after $maxAttempts attempts. Body: $bodySnippet" } }