Files
HellionChat/.gitea/workflows/release.yml
T
JonKazama-Hellion 38c6707970
Security Scan (reusable) / Security Scan (push) Successful in 25s
Security / scan (push) Successful in 25s
Build / Build (Release) (push) Successful in 28s
ci(security): hold API responses in a file instead of piping them onward
Semgrep blocks on gha-curl-pipe-shell, and it is pointing at the publish step
added an hour ago. The rule exists for `curl https://…/install.sh | bash`:
remote content reaching an interpreter. What this actually did was pipe a JSON
response into a python3 -c inline script -- the interpreter and its program both
live in the workflow file, and the server only ever supplied data.

A false positive, then, but the rule cannot see the difference between an
interpreter reading a program from stdin and one reading data, and neither can
the next person to read the step. Responses go to a file and are read from
there. Same shape as the finding suggests, and worth having anyway: a response
that is on disk can be looked at when a call misbehaves, instead of vanishing
into a pipe.

Verified by falsification rather than by a green run: the same ruleset against
the previous revision of this file reports 1 blocking finding, against this one
zero. Without that check a passing scan only proves the rule was not loaded.
2026-08-19 22:49:07 +02:00

223 lines
9.6 KiB
YAML

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 Gitea's "Run workflow" UI and select the
# tag (e.g. v1.4.4) from the Ref dropdown - not main. The Validate tag
# ref step below hard-fails if a non-tag ref is selected: the release
# name and body are both derived from the tag, so a branch ref would
# publish a release named after a branch.
workflow_dispatch:
permissions:
contents: write
jobs:
release:
name: Build and attach release ZIP
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# Validate up-front so a manual dispatch from a branch ref fails loud
# here instead of burning a full build before the publish step notices.
- name: Validate tag ref
run: |
if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then
echo "::error::Release workflow must run on a v*.X.Y tag ref, got ${GITHUB_REF}"
echo "::error::Push a tag, or pick the tag (not main) in the workflow_dispatch Ref dropdown."
exit 1
fi
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup .NET 10
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # 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:
# github.ref_name is the tag because Validate tag ref above
# already enforced refs/tags/v*. Read via env: so the value
# is a PowerShell variable, not inline shell text, and gets
# re-validated against the semver regex below.
TAG_NAME: ${{ 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
# 4-space yaml indent (prettier convention) from each line.
$afterMarker = $raw.Substring($idx + $marker.Length)
$changelogBody = (($afterMarker -split "`r?`n") | ForEach-Object {
if ($_ -match '^ ') { $_.Substring(4) } else { $_ }
}) -join "`n"
# Subblock convention: "**vX.Y.Z — <subtitle> (<date>)**"
# matches verify-changelog-sync.sh and slim-rule grep.
$header = "**v$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**v", 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 "----------------------------------------"
# The tag comes from GITHUB_REF, the body from the step above. Posted with
# curl rather than gitea.com/actions/release-action, which declares
# `using: go` and has to be compiled by the runner -- act cannot do that
# here and the step dies with exec: "go": executable file not found, exit
# 127, after a build that otherwise succeeded. This runs as a plain shell
# step in the job image, which has curl and python3.
#
# Idempotent on purpose: a re-run against an existing release reuses it and
# replaces the asset instead of failing on the duplicate.
- name: Attach to Gitea release
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ZIP_PATH: ${{ steps.locate.outputs.path }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
auth="Authorization: token ${GITEA_TOKEN}"
# Responses land in a file before anything reads them, rather than
# being piped straight into an interpreter. The interpreter is inline
# either way and the server only ever supplies data, but a scanner
# cannot tell those apart from curl-pipe-shell -- and holding the
# response makes it inspectable when a call misbehaves.
get_release_id() {
if curl -sf -H "$auth" "$api/releases/tags/${TAG_NAME}" -o release.json; then
python3 -c 'import json; print(json.load(open("release.json")).get("id",""))'
fi
}
rel_id="$(get_release_id || true)"
if [ -z "$rel_id" ]; then
python3 - <<'PYCREATE' > create.json
import json, os
body = open("release-body.md", encoding="utf-8").read()
print(json.dumps({
"tag_name": os.environ["TAG_NAME"],
"name": os.environ["TAG_NAME"],
"body": body,
"draft": False,
"prerelease": False,
}))
PYCREATE
curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
-d @create.json "$api/releases" -o created.json
rel_id="$(python3 -c 'import json; print(json.load(open("created.json"))["id"])')"
echo "Created release $rel_id for ${TAG_NAME}"
else
echo "Reusing release $rel_id for ${TAG_NAME}"
fi
# A same-named asset from an earlier attempt has to go, or the upload
# collides with it. This is the state a recovery run finds.
curl -sf -H "$auth" "$api/releases/${rel_id}/assets" -o assets.json
old_id="$(python3 -c 'import json; print(next((a["id"] for a in json.load(open("assets.json")) if a["name"]=="latest.zip"), ""))')"
if [ -n "$old_id" ]; then
curl -sf -X DELETE -H "$auth" "$api/releases/${rel_id}/assets/${old_id}"
echo "Replaced existing latest.zip (asset $old_id)"
fi
curl -sf -X POST -H "$auth" \
-F "attachment=@${ZIP_PATH};filename=latest.zip" \
"$api/releases/${rel_id}/assets?name=latest.zip" -o uploaded.json
python3 -c 'import json; a=json.load(open("uploaded.json")); print("Attached", a["name"], a["size"], "bytes")'