Files
Anitrack/.gitea/workflows/release.yml
T
John O'Keefe 0149b24a33 ci(release): publish signed updater assets alongside tarball
After packaging, stage the bare AniTrack-linux-amd64 binary (the updater swaps os.Executable in place; the user tarball single top-level directory would fail the single-entry rule), sign it with wails3 updater sign using UPDATER_SIGNING_KEY (missing secret is a hard error, key shredded after use), split the output into .sha512/.sig sidecars, and extend the idempotent replace-upload loop from 1 asset to all 4. Tarball flow untouched; rc tags get signed assets too, which the beta-channel live proof needs.
2026-09-16 08:52:09 -04:00

316 lines
14 KiB
YAML

name: Release
# Overrides the default run name (the tagged commit's message) so the Actions
# runs list shows "Release 1.6.7" instead.
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
# Builds the Wails Linux binary via `make build`, packages
# AniTrack-<version>.tar.gz from build/, and creates/updates a Gitea Release
# named AniTrack-<version> with the archive attached. Triggered by a plain
# version tag push (1.6.7), or manually via workflow_dispatch with a tag for
# re-runs and secrets tests. Pushing to main does nothing, so
# work-in-progress commits never ship.
#
# Local flow: `./release 1.6.7` (or `make release VERSION=1.6.7`) bumps
# wails.json, commits the bump, creates an annotated tag carrying the
# git-cliff notes, and pushes commit + tag. This workflow verifies
# wails.json matches the tag before building.
#
# Secrets test (verifies environment.go wiring without shipping): tag the
# current bumped commit with a suffix and push, e.g.
# git tag 1.6.7-rc1 && git push origin 1.6.7-rc1
# Tags containing '-' are published as pre-releases; delete the test
# Release/tag afterwards.
on:
push:
tags:
- '[0-9]*.[0-9]*.[0-9]*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. 1.6.7)'
required: true
type: string
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Resolve the target tag for both triggers: explicit input on manual
# dispatch, otherwise the pushed tag ref.
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history ensures the tag annotation (the release notes) is present.
fetch-depth: 0
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
- name: Guard wails.json matches tag
run: |
set -euo pipefail
: "${TAG:?TAG is required}"
# Normalize "v1.6.7" / "AniTrack-1.6.7" to plain "1.6.7" (make release
# only ever creates plain tags; this tolerates manual typos).
NORM="${TAG#v}"
NORM="${NORM#AniTrack-}"
BASE="${NORM%%-*}"
WAILS_VER="$(python3 -c "import json; print(json.load(open('wails.json'))['info']['productVersion'])")"
if [ "${NORM}" = "${BASE}" ]; then
if [ "${WAILS_VER}" != "${NORM}" ]; then
echo "::error::wails.json productVersion (${WAILS_VER}) != tag (${NORM}). Bump via ./release ${NORM} first." >&2
exit 1
fi
else
# Pre-release (e.g. 1.6.7-rc1): wails.json must match the base version.
if [ "${WAILS_VER}" != "${BASE}" ]; then
echo "::error::wails.json productVersion (${WAILS_VER}) != tag base (${BASE}). Bump via ./release ${BASE} first." >&2
exit 1
fi
fi
echo "VERSION=${NORM}" >> "${GITHUB_ENV}"
echo "Version guard passed: wails.json=${WAILS_VER} tag=${NORM}"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache-dependency-path: go.sum
- name: Cache Go build cache
uses: actions/cache@v4
with:
# setup-go only caches modules (GOMODCACHE). This caches compiled
# packages (GOCACHE) so the cgo/WebKit compile goes incremental.
# restore-keys gives a close cache instead of a cold one when
# go.sum changes. First run after adding this still compiles cold
# (it saves); the payoff shows from the second run on.
path: ~/.cache/go-build
key: go-build-1.25-${{ runner.os }}-${{ hashFiles('go.sum') }}
restore-keys: |
go-build-1.25-${{ runner.os }}-
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
# Caches npm's download cache keyed on the frontend lockfile, so
# the `npm install` inside `wails build` stops fetching cold.
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install Wails Linux build dependencies
run: |
set -euo pipefail
sudo apt-get update
# v3 desktop defaults to the GTK4/WebKitGTK 6.0 stack.
sudo apt-get install -y \
build-essential pkg-config \
libgtk-4-dev libwebkitgtk-6.0-dev \
jq
- name: Cache Wails CLI
uses: actions/cache@v4
id: wails-cli
with:
# The compiled CLI binary. Bump the key whenever the @version pin
# below changes, or the old CLI will be silently reused.
path: ~/go/bin/wails3
key: wails3-v3.0.0-beta.20-${{ runner.os }}
- name: Install Wails CLI
if: steps.wails-cli.outputs.cache-hit != 'true'
run: |
set -euo pipefail
go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.20
- name: Add Go bin to PATH
run: echo "${HOME}/go/bin" >> "${GITHUB_PATH}"
- name: Write environment.go from secrets
env:
ANILIST_SECRET_TOKEN: ${{ secrets.ANILIST_SECRET_TOKEN }}
ANILIST_APP_ID: ${{ secrets.ANILIST_APP_ID }}
ANILIST_APP_NAME: ${{ secrets.ANILIST_APP_NAME }}
ANILIST_CALLBACK_URI: ${{ secrets.ANILIST_CALLBACK_URI }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
SIMKL_CALLBACK_URI: ${{ secrets.SIMKL_CALLBACK_URI }}
MAL_CLIENT_ID: ${{ secrets.MAL_CLIENT_ID }}
MAL_CLIENT_SECRET: ${{ secrets.MAL_CLIENT_SECRET }}
MAL_CALLBACK_URI: ${{ secrets.MAL_CALLBACK_URI }}
run: |
set -euo pipefail
# All 10 fields come from Gitea Actions secrets (never committed).
# Values are masked in logs; do not echo them or run with set -x.
for v in ANILIST_SECRET_TOKEN ANILIST_APP_ID ANILIST_APP_NAME ANILIST_CALLBACK_URI SIMKL_CLIENT_ID SIMKL_CLIENT_SECRET SIMKL_CALLBACK_URI MAL_CLIENT_ID MAL_CLIENT_SECRET MAL_CALLBACK_URI; do
if [ -z "${!v:-}" ]; then
echo "::error::Missing secret ${v}. Add it under Settings → Secrets → Actions." >&2
exit 1
fi
done
cat > environment.go <<EOF
package main
var Environment = EnvironmentStruct{
ANILIST_SECRET_TOKEN: "${ANILIST_SECRET_TOKEN}",
ANILIST_APP_ID: "${ANILIST_APP_ID}",
ANILIST_APP_NAME: "${ANILIST_APP_NAME}",
ANILIST_CALLBACK_URI: "${ANILIST_CALLBACK_URI}",
SIMKL_CLIENT_ID: "${SIMKL_CLIENT_ID}",
SIMKL_CLIENT_SECRET: "${SIMKL_CLIENT_SECRET}",
SIMKL_CALLBACK_URI: "${SIMKL_CALLBACK_URI}",
MAL_CLIENT_ID: "${MAL_CLIENT_ID}",
MAL_CLIENT_SECRET: "${MAL_CLIENT_SECRET}",
MAL_CALLBACK_URI: "${MAL_CALLBACK_URI}",
}
EOF
echo "Wrote environment.go from secrets."
- name: Build Linux binary
run: |
set -euo pipefail
export PATH="${HOME}/go/bin:${PATH}"
make build
test -x build/bin/AniTrack || { echo "::error::build/bin/AniTrack missing after make build" >&2; exit 1; }
- name: Package release archive
run: |
set -euo pipefail
: "${VERSION:?VERSION missing from version-guard step}"
STAGE="dist/AniTrack-${VERSION}"
rm -rf dist "AniTrack-${VERSION}.tar.gz"
mkdir -p "${STAGE}/bin"
cp build/bin/AniTrack "${STAGE}/bin/"
cp -r build/icon "${STAGE}/"
cp build/AniTrack.desktop build/install_linux.sh build/README.md "${STAGE}/"
chmod +x "${STAGE}/bin/AniTrack" "${STAGE}/install_linux.sh"
tar -czf "AniTrack-${VERSION}.tar.gz" -C dist "AniTrack-${VERSION}"
tar tzf "AniTrack-${VERSION}.tar.gz"
echo "ARCHIVE=AniTrack-${VERSION}.tar.gz" >> "${GITHUB_ENV}"
- name: Create Gitea Release
env:
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
# token cannot create releases on this instance. The PAT must carry
# write:repository scope. Idempotent: re-runs update an existing
# release for this tag instead of failing with 409. On any HTTP error
# the API response body is printed so a 403 names the missing scope.
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REPO: ${{ gitea.repository }}
run: |
set -euo pipefail
: "${TAG:?TAG is required}"
: "${VERSION:?VERSION missing from version-guard step}"
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
AUTH="Authorization: token ${TOKEN}"
# Release body = the annotated tag's message (the git-cliff notes).
# Manual test tags without an annotation fall back to the tag name.
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
if [ -z "$(printf '%s' "${BODY}" | tr -d '[:space:]')" ]; then
BODY="${TAG}"
fi
# Tags containing a '-' (e.g. 1.6.7-rc1) are published as pre-releases.
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
PAYLOAD=$(jq -n \
--arg t "${TAG}" --arg n "AniTrack-${VERSION}" --arg b "${BODY}" --argjson p "${PRE}" \
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
# POST/PATCH the release, surfacing Gitea's error message on failure
# (e.g. "token does not have write scope") instead of failing silently.
api_call() {
local method="$1" url="$2" resp code rbody
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
-H "${AUTH}" -H "Content-Type: application/json" \
-d "${PAYLOAD}" "${url}")"
code="$(printf '%s' "${resp}" | tail -n1)"
rbody="$(printf '%s' "${resp}" | sed '$d')"
if [ "${code}" -ge 400 ]; then
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
return 1
fi
}
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
if [ -n "${EXISTING_ID}" ]; then
api_call PATCH "${API}/${EXISTING_ID}"
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
else
api_call POST "${API}"
echo "Created new release for ${TAG}"
fi
- name: Build updater asset and sign
env:
UPDATER_SIGNING_KEY: ${{ secrets.UPDATER_SIGNING_KEY }}
run: |
set -euo pipefail
: "${VERSION:?VERSION missing from version-guard step}"
export PATH="${HOME}/go/bin:${PATH}"
: "${UPDATER_SIGNING_KEY:?Missing secret UPDATER_SIGNING_KEY. Add the updater private key under Settings → Secrets → Actions.}"
# Bare updater binary: the updater swaps os.Executable() in place,
# so it takes the single binary, not the user tarball (whose
# top-level directory would fail the single-entry rule).
mkdir -p updater-dist
cp build/bin/AniTrack updater-dist/AniTrack-linux-amd64
printf '%s' "${UPDATER_SIGNING_KEY}" > updater-dist/updater.key
chmod 600 updater-dist/updater.key
wails3 updater sign -key updater-dist/updater.key updater-dist/AniTrack-linux-amd64 > updater-dist/sign.json
# Split the sign output into the sidecar assets the Gitea provider
# fetches: <name>.sha512 (sha512sum format) + <name>.sig (base64).
python3 - <<'EOF'
import base64, binascii, json
entries = json.load(open('updater-dist/sign.json'))
entry = next(e for e in entries if e['filename'].endswith('AniTrack-linux-amd64'))
assert entry['digestAlgo'] == 'sha512', entry
assert entry['signatureAlgo'] == 'ed25519ph', entry
digest_hex = binascii.hexlify(base64.b64decode(entry['digest'])).decode()
open('updater-dist/AniTrack-linux-amd64.sha512', 'w').write(f"{digest_hex} AniTrack-linux-amd64\n")
open('updater-dist/AniTrack-linux-amd64.sig', 'w').write(entry['signature'].strip() + '\n')
EOF
shred -u updater-dist/updater.key
ls -la updater-dist/
- name: Upload release assets
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REPO: ${{ gitea.repository }}
run: |
set -euo pipefail
: "${TAG:?TAG is required}"
: "${ARCHIVE:?ARCHIVE missing from packaging step}"
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
AUTH="Authorization: token ${TOKEN}"
for ASSET in "${ARCHIVE}" updater-dist/AniTrack-linux-amd64 updater-dist/AniTrack-linux-amd64.sha512 updater-dist/AniTrack-linux-amd64.sig; do
test -f "${ASSET}" || { echo "::error::${ASSET} not found" >&2; exit 1; }
RID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty')"
if [ -z "${RID}" ]; then
echo "::error::No release found for tag ${TAG} after create step" >&2
exit 1
fi
# Replace a same-named asset so re-runs stay idempotent.
ANAME="$(basename "${ASSET}")"
AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ANAME}" '.[] | select(.name==$n) | .id // empty')"
if [ -n "${AID}" ]; then
curl -sS -X DELETE -H "${AUTH}" "${API}/${RID}/assets/${AID}" >/dev/null
echo "Deleted existing asset id=${AID} (${ANAME})"
fi
resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \
-F "attachment=@${ASSET}" "${API}/${RID}/assets?name=${ANAME}")"
code="$(printf '%s' "${resp}" | tail -n1)"
rbody="$(printf '%s' "${resp}" | sed '$d')"
if [ "${code}" -ge 400 ]; then
echo "::error::Asset upload ${code}: ${rbody}" >&2
exit 1
fi
echo "Uploaded ${ANAME} to release id=${RID}"
done