From 270faeb50712ac7a326befdd054b60b246383e46 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 11 Sep 2026 16:00:37 -0400 Subject: [PATCH] feat(ci): automate Linux release builds and Gitea releases Local flow is now ./release 1.6.7 (or make release VERSION=1.6.7): bump wails.json productVersion, commit the bump, generate git-cliff notes into an annotated plain-version tag, and push commit plus tag. The tag push fires the new Gitea Actions workflow. - cliff.toml: git-cliff groups matching Bookhoard, tag_pattern for plain versions so --latest scopes correctly. - Makefile release target: strict plain-semver validation, dirty-tree and existing-tag guards, python3 wails.json bump committed as chore(release), throwaway-tag notes generation, push of main + tag. - release: wrapper normalizing v/AniTrack- prefixes to plain versions. - .gitea/workflows/release.yml: version guard (wails.json vs tag, base-match for -suffix pre-releases), Go 1.25 / Node 20 / Wails v2.15.0 + webkit2_41 system deps, environment.go from Actions secrets, make build, AniTrack-.tar.gz packaged from build/ (bin, icon, desktop, install script, README), idempotent Release create/update named AniTrack- with archive attached via REGISTRY_TOKEN PAT. --- .gitea/workflows/release.yml | 251 +++++++++++++++++++++++++++++++++++ Makefile | 41 +++++- cliff.toml | 37 ++++++ release | 17 +++ 4 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 cliff.toml create mode 100755 release diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..dd25276 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,251 @@ +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-.tar.gz from build/, and creates/updates a Gitea Release +# named AniTrack- 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: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Wails Linux build dependencies + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y \ + build-essential pkg-config \ + libgtk-3-dev libwebkit2gtk-4.1-dev \ + jq + + - name: Install Wails CLI + run: | + set -euo pipefail + go install github.com/wailsapp/wails/v2/cmd/wails@v2.15.0 + 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 <&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: Upload release archive + 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}" + test -f "${ARCHIVE}" || { echo "::error::${ARCHIVE} 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. + AID="$(curl -sS -H "${AUTH}" "${API}/${RID}/assets" | jq -r --arg n "${ARCHIVE}" '.[] | 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} (${ARCHIVE})" + fi + + resp="$(curl -sS -w '\n%{http_code}' -X POST -H "${AUTH}" \ + -F "attachment=@${ARCHIVE}" "${API}/${RID}/assets?name=${ARCHIVE}")" + 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 ${ARCHIVE} to release id=${RID}" diff --git a/Makefile b/Makefile index b778465..e388fe7 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ TAGS := webkit2_41 -.PHONY: dev build clean +.PHONY: dev build clean release dev: wails dev -tags $(TAGS) @@ -10,3 +10,42 @@ build: clean: rm -rf build/bin/* + +# Create a version commit (wails.json bump, committed and pushed) plus an +# annotated version tag carrying auto-generated release notes (git-cliff), +# and push both. The tag push triggers .gitea/workflows/release.yml, which +# verifies wails.json matches the tag, builds via `make build`, packages +# AniTrack-.tar.gz from build/, and publishes a Gitea Release +# named AniTrack- with the archive attached. Notes come entirely +# from Conventional Commits — no hand-written message required. +# +# git-cliff's --latest needs the tag to exist to scope the notes, so we +# create a throwaway lightweight tag, generate the notes, replace it with +# an annotated tag, then push. --cleanup=verbatim keeps the markdown "###" +# group headers (git's default cleanup would strip lines starting with "#"). +# +# Tags are plain versions (1.6.7) to match wails.json productVersion and the +# existing tag history. Pre-releases are created by tagging manually with a +# suffix (e.g. 1.6.7-rc1 on top of the bumped commit) — the workflow marks +# those as prerelease. Use ./release as a shortcut. +# +# Requires git-cliff: https://git-cliff.org/install +# Usage: make release VERSION=1.6.7 +release: + @test -n "$(VERSION)" || { echo "Usage: make release VERSION=1.6.7"; exit 1; } + @echo "$(VERSION)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "VERSION must be plain semver like 1.6.7 (no v prefix, no AniTrack- prefix, no suffix)"; exit 1; } + @command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; } + @command -v python3 >/dev/null 2>&1 || { echo "python3 not found — required to bump wails.json"; exit 1; } + @git diff --quiet && git diff --cached --quiet || { echo "Working tree dirty — commit or stash first"; exit 1; } + @if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi + @echo "Bumping wails.json to $(VERSION)..." + @python3 -c "import json; p='wails.json'; d=json.load(open(p)); d['info']['productVersion']='$(VERSION)'; json.dump(d, open(p,'w'), indent=2); open(p,'a').write('\n')" + @git add wails.json + @git commit -m "chore(release): bump version to $(VERSION)" + @echo "Generating release notes for $(VERSION)..." + @git tag "$(VERSION)" HEAD && \ + (git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \ + { git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; } + @git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp + @git push origin main "$(VERSION)" + @echo "Pushed $(VERSION) — Gitea Actions will build, package, and publish AniTrack-$(VERSION).tar.gz." diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..a0c8eaa --- /dev/null +++ b/cliff.toml @@ -0,0 +1,37 @@ +# git-cliff configuration — generates the body of each Gitea Release from +# Conventional Commits accumulated since the previous tag. Invoked locally by +# `make release VERSION=...`; the annotated tag message becomes the Release +# body in .gitea/workflows/release.yml. Tags are plain versions (1.6.7). +# Docs: https://git-cliff.org/docs/configuration + +[changelog] +header = "" +body = """ +{% for group, commits in commits | group_by(attribute="group") %}\ +### {{ group | upper_first }} +{% for commit in commits %}\ +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }}) +{% endfor %}\ +{% endfor %}\ +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = false +require_conventional = false +split_commits = false +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Tests" }, + { message = "^chore|^ci", group = "Miscellaneous Tasks" }, + { message = ".*", group = "Other" }, +] +filter_commits = false +tag_pattern = "[0-9].*" +sort_commits = "oldest" diff --git a/release b/release new file mode 100755 index 0000000..e30e81d --- /dev/null +++ b/release @@ -0,0 +1,17 @@ +#!/usr/bin/env sh +# Project-attached wrapper around `make release` so you can run: +# ./release 1.6.7 (or) ./release v1.6.7 (or) ./release AniTrack-1.6.7 +# instead of: +# make release VERSION=1.6.7 +# Lives in the repo (no machine-specific alias needed). Tags are plain +# versions (1.6.7) to match wails.json productVersion. +set -eu + +[ "$#" -ge 1 ] || { echo "Usage: ./release 1.6.7" >&2; exit 1; } + +# Normalize "v1.6.7" or "AniTrack-1.6.7" down to plain "1.6.7" (the Makefile +# rejects anything that is not plain semver, so this just saves typing). +VERSION="${1#v}" +VERSION="${VERSION#AniTrack-}" + +exec make release "VERSION=${VERSION}"