diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 80c784f..46f7699 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,13 +1,21 @@ name: Release -# Builds and publishes the Bookhoard container image to the Gitea container registry. -# Triggered ONLY by a version tag push (pushing to main does nothing), so work-in-progress -# commits never ship. Each release publishes two image tags: the version and "latest". +# Publishes the Bookhoard container image to the Gitea container registry AND +# creates a Gitea Release whose body is the annotated tag's message (generated +# locally by `make release VERSION=...` via git-cliff). Triggered by a version +# tag push, or manually via workflow_dispatch with a tag. Pushing to main does +# nothing, so work-in-progress commits never ship. Each release publishes two +# image tags: the version (e.g. v0.3.0) and "latest". on: push: tags: - 'v*' workflow_dispatch: + inputs: + tag: + description: 'Tag to release (e.g. v0.3.0)' + required: true + type: string jobs: build-and-push: @@ -15,9 +23,17 @@ jobs: permissions: contents: read packages: write + 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: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -40,5 +56,53 @@ jobs: # Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml; # pin or roll back by setting IMAGE_TAG in .env. tags: | - git.linuxhg.com/bookhoard/bookhoard:${{ gitea.ref_name }} + git.linuxhg.com/bookhoard/bookhoard:${{ env.TAG }} git.linuxhg.com/bookhoard/bookhoard:latest + + - name: Create Gitea Release + env: + # REGISTRY_TOKEN is reused for release creation because Gitea's auto + # GITHUB_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}" + 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). + BODY="$(git tag -l --format='%(contents)' "${TAG}")" + + # Tags containing a '-' (e.g. v0.3.0-rc1) are published as pre-releases. + PRE="false"; case "${TAG}" in *-*) PRE="true";; esac + + PAYLOAD=$(jq -n \ + --arg t "${TAG}" --arg n "${TAG}" --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 diff --git a/Makefile b/Makefile index cea32de..c986827 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick +.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release # Include .env file for environment variables (single source of truth) # Ignore if .env doesn't exist yet @@ -44,6 +44,9 @@ help: @echo "Verification:" @echo " make verify-guidelines - Run comprehensive guidelines check" @echo " make verify-quick - Run quick guidelines check" + @echo "" + @echo "Release:" + @echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)" # Run unit tests locally (fast, no containers) test: @@ -158,3 +161,27 @@ verify-guidelines: verify-quick: @echo "Running quick project guidelines verification..." @./scripts/verify-quick.sh + +# Create an annotated version tag carrying auto-generated release notes (git-cliff) +# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the +# image and publishes a Gitea Release whose body is this tag's message. 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 "#"). +# +# Requires git-cliff: https://git-cliff.org/install +# Usage: make release VERSION=v0.3.0 +release: + @test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; } + @command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; 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 "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 "$(VERSION)" + @echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release." diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..e23533c --- /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 in CI by +# orhun/git-cliff-action with --latest so only the current tag's section is +# emitted (no full history, no header — the Gitea Release title is the tag). +# 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 = "v[0-9].*" +sort_commits = "oldest" diff --git a/release b/release new file mode 100755 index 0000000..3aac254 --- /dev/null +++ b/release @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +# Project-attached wrapper around `make release` so you can run: +# ./release v0.3.0 (or) ./release 0.3.0 +# instead of: +# make release VERSION=v0.3.0 +# Lives in the repo (no machine-specific alias needed). +set -eu + +[ "$#" -ge 1 ] || { echo "Usage: ./release v0.3.0" >&2; exit 1; } + +# Accept "0.3.0" or "v0.3.0"; ensure the tag starts with 'v' (the workflow +# only triggers on v* tags). +VERSION="v${1#v}" + +exec make release "VERSION=${VERSION}"