5771573a94
Both the DE and EN embed carried the same release url, which makes Discord merge url-identical embeds and render only the first embed's description. The EN block was posted and stored but never shown, so every auto-announce from v1.4.6 onward displayed German only. Drop the url from the EN embed so Discord stacks both as separate cards with both descriptions visible.
256 lines
12 KiB
YAML
256 lines
12 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(4) } else { $_ }
|
|
}) -join "`n"
|
|
|
|
$header = "**v$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**v", 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()
|
|
}
|
|
|
|
# ---------- Embed-Felder + Per-Field-Caps (Discord-Hard-Limits) ----------
|
|
# Discord enforces per-embed-field limits separately from the
|
|
# combined-total limit. We split the DE and EN blocks into two
|
|
# embeds that share the same release URL so Discord stitches
|
|
# them into one visual card. Hard caps per Discord docs:
|
|
# description: 4096 per embed
|
|
# title: 256 per embed
|
|
# footer.text: 2048 per embed
|
|
# combined sum across all embeds: 6000
|
|
$title = "Hellion Chat $version — $subtitle"
|
|
$deDesc = "**Deutsch**`n`n$deBody"
|
|
$enDesc = "**English**`n`n$enBlock"
|
|
$footerText = "Hellion Forge · $versionsnatur"
|
|
$releaseUrl = "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/tag/$tag"
|
|
|
|
if ($deDesc.Length -gt 4096) {
|
|
throw "V6a: DE-Body too long for one embed ($($deDesc.Length) chars, max 4096). Trim .github/forge-posts/$tag.md or post the announcement manually (see forge style §8)."
|
|
}
|
|
if ($enDesc.Length -gt 4096) {
|
|
throw "V6b: EN-Block too long for one embed ($($enDesc.Length) chars, max 4096). Trim the changelog entry in HellionChat/HellionChat.yaml or post manually."
|
|
}
|
|
$totalChars = $title.Length + $deDesc.Length + $enDesc.Length + $footerText.Length
|
|
if ($totalChars -gt 6000) {
|
|
throw "V6c: Combined embed chars $totalChars exceed Discord's 6000-total limit. Major-Release detected — post manually via Bot/Multi-Embed (see forge style §8)."
|
|
}
|
|
Write-Host "Embed-Caps OK: de=$($deDesc.Length)/4096, en=$($enDesc.Length)/4096, total=$totalChars/6000"
|
|
|
|
# ---------- Embed-Payload bauen (zwei gestapelte Embeds) ----------
|
|
# Discord MERGES embeds in one message that share the same `url`
|
|
# (the image-gallery merge) and then renders only the FIRST embed's
|
|
# description — every following embed contributes images only. So
|
|
# only the DE embed carries the release URL; the EN embed stays
|
|
# url-less, which makes Discord stack both as separate cards with
|
|
# both descriptions visible. Title sits on the first embed, footer
|
|
# + timestamp on the last so it still reads as one post.
|
|
$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 = $releaseUrl
|
|
color = 12730636
|
|
description = $deDesc
|
|
},
|
|
[ordered]@{
|
|
# Deliberately no `url` — a shared url would make Discord
|
|
# merge this embed into the first and drop the EN body.
|
|
color = 12730636
|
|
description = $enDesc
|
|
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"
|
|
}
|
|
}
|