e594258cf3
Cleanup pass after the v1.4.3 cutover. Five files still carried gitea.com hosts or dead github.com security-advisory links because they were not touched in the prior URL sweep. - forge-announce.yml: Discord embed avatar and tag link - release-footer.md: custom-repo URL plus six doc/license links - bug_report.yml, config.yml, PULL_REQUEST_TEMPLATE.md: replace github.com/.../security/advisories/new with mailto:kontakt@ hellion-media.de. Gitea has no privately-reportable advisory feature; e-mail is the closest functional equivalent. Pure string replacement, no logic change.
226 lines
11 KiB
YAML
226 lines
11 KiB
YAML
name: Forge Announce
|
|
|
|
# Triggered when a vX.Y.Z tag is pushed. Reads .github/forge-posts/<tag>.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.hellion-forge.cloud/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.hellion-forge.cloud/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"
|
|
}
|
|
}
|