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 — ()**" # 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")'