chore(ci): migrate workflows to .gitea/workflows/
Security / scan (push) Successful in 19s
Build / Build (Release) (push) Successful in 42s

Gitea Actions reads exclusively from .gitea/workflows/, not from
.github/workflows/. Since the cutover in v1.4.3 only the security
workflow has been running — release and forge-announce silently sat in
the wrong directory and never fired on any tag push. v1.4.3 must have
been released manually.

Move build, release and forge-announce yamls to .gitea/workflows/. The
.github/forge-posts/ and .github/release-footer.md data files stay where
they are; the workflows reference them by repo-relative path and that
keeps working.

For the v1.4.4 backfill: workflow_dispatch via the Gitea web UI with
tag=v1.4.4 will run release.yml + forge-announce.yml against the tagged
tree (which doesn't contain this migration). The dispatch yaml itself
is read from the default branch, not the tag, so the missing yamls in
the v1.4.4 tag tree don't matter.
This commit is contained in:
2026-05-12 11:05:52 +02:00
parent 8bf50151d5
commit be17472cd5
3 changed files with 0 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
name: Build
# Verifies that every push to main and every PR still builds against the
# current Dalamud staging branch. Does not produce release artefacts; the
# release workflow handles that on tag.
#
# Linux runner: gitea.com Cloud Actions provides ubuntu-latest. The plugin
# csproj targets net10.0-windows, but `dotnet build` cross-compiles on
# Linux as long as the Dalamud staging assemblies are present at the
# expected lookup path ($(HOME)/.xlcore/dalamud/Hooks/dev/, which the
# Dalamud SDK 15 uses on Linux).
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
# Minimum permissions for a build-only workflow: read the repo, nothing
# else. Closes the CodeQL "Workflow does not contain permissions" alert
# and matches the principle-of-least-privilege the security guide
# recommends for workflows that don't push or create releases.
permissions:
contents: read
jobs:
build:
name: Build (Release)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup .NET 10
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5
with:
dotnet-version: 10.0.x
- name: Download Dalamud staging
run: |
hooks="$HOME/.xlcore/dalamud/Hooks/dev"
mkdir -p "$hooks"
curl -fsSL https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -o dalamud.zip
unzip -oq dalamud.zip -d "$hooks"
- name: Restore
run: dotnet restore HellionChat/HellionChat.csproj
- name: Build (Release)
run: dotnet build HellionChat/HellionChat.csproj --configuration Release --no-restore
+225
View File
@@ -0,0 +1,225 @@
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"
}
}
+170
View File
@@ -0,0 +1,170 @@
name: Release
# Triggered when a vX.Y.Z tag is pushed. Builds the plugin against the
# current Dalamud staging branch, locates the latest.zip produced by
# DalamudPackager and attaches it to the matching Gitea Release.
#
# User-controlled inputs touched by this workflow:
# - the tag name (filtered by on.tags = v*, validated again at runtime
# against ^v\d+\.\d+\.\d+$ before being used in any string)
# All other values are either repo-controlled (paths under
# HellionChat/bin/Release derived from find / Get-ChildItem) or pinned
# URLs to goatcorp / gitea. Nothing from a webhook event payload (issue/PR
# titles, commit messages, etc.) flows into a run-step.
#
# Linux runner: gitea.com Cloud Actions only ships ubuntu-latest. The
# plugin csproj targets net10.0-windows, `dotnet build` cross-compiles on
# Linux when the Dalamud staging assemblies sit under $(HOME)/.xlcore/...
on:
push:
tags:
- "v*"
# Manual recovery trigger. Use when a tag was pushed but the auto-run
# was missed or failed: `gh workflow run release.yml -f tag=v0.6.1`.
# The tag input is validated against the same semver regex as the
# auto-trigger before any string interpolation happens.
workflow_dispatch:
inputs:
tag:
description: "Existing tag to (re)release, e.g. v0.6.1"
required: true
type: string
permissions:
contents: write
jobs:
release:
name: Build and attach release ZIP
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# On push:tags, github.ref_name is the tag — checkout default works.
# On workflow_dispatch, ref defaults to the branch the action was
# invoked from; we need to explicitly check out the tag the user
# supplied so the build comes from the tagged commit, not main.
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Setup .NET 10
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5
with:
dotnet-version: 10.0.x
- name: Download Dalamud staging
run: |
hooks="$HOME/.xlcore/dalamud/Hooks/dev"
mkdir -p "$hooks"
curl -fsSL https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -o dalamud.zip
unzip -oq dalamud.zip -d "$hooks"
- name: Build (Release)
run: dotnet build HellionChat/HellionChat.csproj --configuration Release
- name: Locate latest.zip
id: locate
run: |
zip="$(find HellionChat/bin/Release -name latest.zip -print -quit)"
if [ -z "$zip" ]; then
echo "latest.zip not found under HellionChat/bin/Release" >&2
exit 1
fi
echo "Found: $zip"
echo "path=$zip" >> "$GITHUB_OUTPUT"
# Build a release body from the matching changelog block in
# HellionChat.yaml plus a static install / docs footer. Fails the
# workflow if no block exists for the tagged version, which is the
# automated counterpart to the "yaml + repo.json + release body
# kept in sync" rule.
#
# GITHUB_REF_NAME is read via env: (not ${{ }} interpolation) so the
# tag value is treated as a PowerShell variable, not as inline shell
# text. The strict regex below rejects anything that is not a clean
# semver tag before it is used to build a string.
- name: Generate release body
shell: pwsh
env:
# workflow_dispatch carries the user-supplied tag in inputs.tag;
# push:tags carries it in github.ref_name. Either way the value
# is treated as a PowerShell variable (env-var pass), not as
# inline shell text, and validated against the semver regex
# below before any string interpolation.
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
run: |
$tag = $env:TAG_NAME
if ($tag -notmatch '^v\d+\.\d+\.\d+$') {
throw "Refusing to generate release body for non-semver tag: $tag"
}
$version = $tag.Substring(1)
$yamlPath = "HellionChat/HellionChat.yaml"
$raw = Get-Content -Path $yamlPath -Raw
$marker = "changelog: |-"
$idx = $raw.IndexOf($marker)
if ($idx -lt 0) { throw "changelog block not found in $yamlPath" }
# changelog: is the last top-level key in the manifest, so
# everything after the marker is the literal block. Strip the
# 2-space yaml indent from each line.
$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 "No changelog entry for version $version found in $yamlPath. Update the changelog block before tagging a release."
}
$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)) {
$currentBlock = $rest.Substring(0, $nextHdr).TrimEnd()
} elseif ($trailer -ge 0) {
$currentBlock = $rest.Substring(0, $trailer).TrimEnd()
} else {
$currentBlock = $rest.TrimEnd()
}
# Static install / docs / licence footer is maintained as a
# separate file so the workflow YAML stays clean (no embedded
# heredoc that would have to be indented under the run-block).
$footerPath = ".github/release-footer.md"
if (-not (Test-Path $footerPath)) {
throw "Release footer template not found: $footerPath"
}
$footer = Get-Content -Path $footerPath -Raw
$body = $currentBlock + "`n" + $footer
$body | Out-File -FilePath release-body.md -Encoding utf8 -NoNewline
Write-Host "Generated release body for $tag :"
Write-Host "----------------------------------------"
Write-Host $body
Write-Host "----------------------------------------"
# Gitea-native release action. Creates the release if the tag has no
# release yet, or updates the existing one. body_path provides the
# generated release body, files attaches latest.zip. The auto-injected
# GITHUB_TOKEN on Gitea Actions has Gitea-API scope and is sufficient
# for release write.
- name: Attach to Gitea release
uses: https://gitea.com/actions/release-action@main
with:
# Explicit tag_name so the action targets the correct release in
# both push:tags (auto) and workflow_dispatch (manual recovery)
# modes. Without this, dispatch runs would default to the branch
# ref (main) and fail to find the release.
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
files: ${{ steps.locate.outputs.path }}
body_path: release-body.md
api_key: ${{ secrets.GITHUB_TOKEN }}