From 615b16e8f3b6241504ef58834e6f3862b3ef840c Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Thu, 8 Jan 2026 19:03:49 +0100 Subject: [PATCH] feat: add Release workflow for automated releases --- .gitea/workflows/Release.yml | 410 +++++++++++++++++++++++++++++++++++ README.md | 40 +++- 2 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/Release.yml diff --git a/.gitea/workflows/Release.yml b/.gitea/workflows/Release.yml new file mode 100644 index 0000000..2d7a7fc --- /dev/null +++ b/.gitea/workflows/Release.yml @@ -0,0 +1,410 @@ +name: Unbound Release + +on: + workflow_call: + inputs: + tag_only: + description: 'Set to true to only create tags without full releases' + required: false + default: false + type: boolean + secrets: + UNBOUND_RELEASE_TOKEN: + description: 'Token with API access to create PRs and releases' + required: true + +env: + GITEA_URL: https://git.unbound.se + +jobs: + preconditions: + name: Check Preconditions + runs-on: ubuntu-latest + container: + image: amd64/alpine:3.22.2@sha256:b687e78c6e2785808446f45b52f1540a1e58adc07bdcffea354933b18c613d90 + steps: + - name: Validate token + if: ${{ secrets.UNBOUND_RELEASE_TOKEN == '' }} + run: | + echo "To use Unbound Release, a UNBOUND_RELEASE_TOKEN secret needs to be defined." + echo "It needs API access to write repository files, create PRs and releases." + echo " " + echo "Create a token in Gitea: Settings -> Applications -> Generate New Token" + echo "Required scopes: repository (read/write), issue (read/write)" + exit 1 + + changelog: + name: Generate Changelog + runs-on: ubuntu-latest + needs: preconditions + if: github.ref_type == 'branch' && github.ref_name == github.event.repository.default_branch + container: + image: orhunp/git-cliff:2.10.1@sha256:6ba0d1fcb051bd7b154cfb19c4b2b3bfa2c22c475f5285fc30606777b6573119 + outputs: + version: ${{ steps.version.outputs.version }} + has_changes: ${{ steps.check.outputs.has_changes }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + run: | + git-cliff --bump --unreleased --strip header > CHANGES.md + git-cliff --bump | sed "s/[[:space:]]\+$//" > CHANGELOG.md + + - name: Get bumped version + id: version + run: | + VERSION=$(git-cliff --bumped-version 2>/dev/null || echo "") + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "${VERSION}" > VERSION + + - name: Check for changes + id: check + run: | + LATEST=$(cat .version 2>/dev/null | jq -r '.version' 2>/dev/null || git describe --abbrev=0 --tags 2>/dev/null || echo '') + VERSION=$(cat VERSION) + if [ -n "${LATEST}" ] && [ "${VERSION}" = "${LATEST}" ]; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: changelog-artifacts + path: | + CHANGES.md + CHANGELOG.md + VERSION + + handle-pr: + name: Handle Release PR + runs-on: ubuntu-latest + needs: changelog + if: needs.changelog.outputs.has_changes == 'true' + container: + image: amd64/alpine:3.22.2@sha256:b687e78c6e2785808446f45b52f1540a1e58adc07bdcffea354933b18c613d90 + steps: + - name: Install dependencies + run: apk add --no-cache git jq curl + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: changelog-artifacts + + - name: Create or update release PR + env: + TOKEN: ${{ secrets.UNBOUND_RELEASE_TOKEN }} + REPOSITORY: ${{ github.repository }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + VERSION=$(cat VERSION) + OWNER=$(echo "${REPOSITORY}" | cut -d'/' -f1) + REPO=$(echo "${REPOSITORY}" | cut -d'/' -f2) + API_URL="${GITEA_URL}/api/v1/repos/${OWNER}/${REPO}" + + TITLE="chore(release): prepare for ${VERSION}" + # Read CHANGES.md and escape for JSON + DESCRIPTION=$(cat CHANGES.md | jq -Rs .) + DESCRIPTION="${DESCRIPTION:1:-1}" # Remove surrounding quotes from jq + + # Add squash merge reminder + DESCRIPTION="${DESCRIPTION} + + --- + **Note:** Please use **Squash Merge** when merging this PR." + + echo "Checking for existing release PRs..." + PRS=$(curl -sf \ + -H "Authorization: token ${TOKEN}" \ + "${API_URL}/pulls?state=open" | jq '[.[] | select(.head.ref == "next-release")]') + PR_INDEX=$(echo "${PRS}" | jq -r '.[0].number // empty') + + echo "Checking for existing next-release branch..." + BRANCH_EXISTS=$(curl -sf \ + -H "Authorization: token ${TOKEN}" \ + "${API_URL}/branches/next-release" 2>/dev/null && echo "true" || echo "false") + + # Prepare CHANGELOG.md content + CHANGELOG_CONTENT=$(base64 -w0 < CHANGELOG.md) + + # Prepare .version content + VERSION_JSON=$(jq -n --arg v "${VERSION}" '{"version":$v}') + VERSION_CONTENT=$(echo "${VERSION_JSON}" | base64 -w0) + + if [ "${BRANCH_EXISTS}" = "true" ]; then + echo "Updating existing next-release branch..." + + # Get SHA of existing CHANGELOG.md + CHANGELOG_SHA=$(curl -sf \ + -H "Authorization: token ${TOKEN}" \ + "${API_URL}/contents/CHANGELOG.md?ref=next-release" | jq -r '.sha // empty') + + # Update or create CHANGELOG.md + if [ -n "${CHANGELOG_SHA}" ]; then + curl -sf -X PUT \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg content "${CHANGELOG_CONTENT}" \ + --arg sha "${CHANGELOG_SHA}" \ + --arg message "${TITLE}" \ + --arg branch "next-release" \ + '{content: $content, sha: $sha, message: $message, branch: $branch}')" \ + "${API_URL}/contents/CHANGELOG.md" + else + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg content "${CHANGELOG_CONTENT}" \ + --arg message "${TITLE}" \ + --arg branch "next-release" \ + '{content: $content, message: $message, branch: $branch, new_branch: $branch}')" \ + "${API_URL}/contents/CHANGELOG.md" + fi + + # Get SHA of existing .version + VERSION_SHA=$(curl -sf \ + -H "Authorization: token ${TOKEN}" \ + "${API_URL}/contents/.version?ref=next-release" | jq -r '.sha // empty') + + # Update or create .version + if [ -n "${VERSION_SHA}" ]; then + curl -sf -X PUT \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg content "${VERSION_CONTENT}" \ + --arg sha "${VERSION_SHA}" \ + --arg message "${TITLE}" \ + --arg branch "next-release" \ + '{content: $content, sha: $sha, message: $message, branch: $branch}')" \ + "${API_URL}/contents/.version" + else + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg content "${VERSION_CONTENT}" \ + --arg message "${TITLE}" \ + --arg branch "next-release" \ + '{content: $content, message: $message, branch: $branch}')" \ + "${API_URL}/contents/.version" + fi + else + echo "Creating new next-release branch with CHANGELOG.md..." + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg content "${CHANGELOG_CONTENT}" \ + --arg message "${TITLE}" \ + --arg branch "next-release" \ + --arg new_branch "next-release" \ + '{content: $content, message: $message, branch: $branch, new_branch: $new_branch}')" \ + "${API_URL}/contents/CHANGELOG.md" + + echo "Adding .version to next-release branch..." + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg content "${VERSION_CONTENT}" \ + --arg message "${TITLE}" \ + --arg branch "next-release" \ + '{content: $content, message: $message, branch: $branch}')" \ + "${API_URL}/contents/.version" + fi + + if [ -n "${PR_INDEX}" ]; then + echo "Updating existing PR #${PR_INDEX}..." + curl -sf -X PATCH \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg title "${TITLE}" \ + --arg body "${DESCRIPTION}" \ + '{title: $title, body: $body}')" \ + "${API_URL}/pulls/${PR_INDEX}" + else + echo "Creating new PR..." + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg title "${TITLE}" \ + --arg body "${DESCRIPTION}" \ + --arg head "next-release" \ + --arg base "${DEFAULT_BRANCH}" \ + '{title: $title, body: $body, head: $head, base: $base}')" \ + "${API_URL}/pulls" + fi + + prepare-release: + name: Prepare Release + runs-on: ubuntu-latest + needs: preconditions + if: | + (github.ref_type == 'branch' && github.ref_name == github.event.repository.default_branch) || + github.ref_type == 'tag' + container: + image: orhunp/git-cliff:2.10.1@sha256:6ba0d1fcb051bd7b154cfb19c4b2b3bfa2c22c475f5285fc30606777b6573119 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + git-cliff --bump --latest --strip header > CHANGES.md + else + git-cliff --bump --unreleased --strip header > CHANGES.md + fi + + - name: Get version + id: version + run: | + VERSION=$(git-cliff --bumped-version 2>/dev/null || echo "") + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "${VERSION}" > VERSION + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: release-artifacts + path: | + CHANGES.md + VERSION + + create-release: + name: Create Release + runs-on: ubuntu-latest + needs: prepare-release + if: | + github.ref_type == 'branch' && + github.ref_name == github.event.repository.default_branch && + inputs.tag_only != true + container: + image: amd64/alpine:3.22.2@sha256:b687e78c6e2785808446f45b52f1540a1e58adc07bdcffea354933b18c613d90 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install dependencies + run: apk add --no-cache git jq curl + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: release-artifacts + + - name: Create release + env: + TOKEN: ${{ secrets.UNBOUND_RELEASE_TOKEN }} + REPOSITORY: ${{ github.repository }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ ! -r .version ]; then + echo "Version file not found" + exit 0 + fi + + VERSION=$(cat .version 2>/dev/null | jq -r '.version') + LATEST=$(git describe --abbrev=0 --tags 2>/dev/null || echo '') + + if [ -n "${LATEST}" ] && [ "${VERSION}" = "${LATEST}" ]; then + echo "Version ${VERSION} already exists" + exit 0 + fi + + OWNER=$(echo "${REPOSITORY}" | cut -d'/' -f1) + REPO=$(echo "${REPOSITORY}" | cut -d'/' -f2) + API_URL="${GITEA_URL}/api/v1/repos/${OWNER}/${REPO}" + + NAME=$(cat VERSION) + MESSAGE=$(cat CHANGES.md | jq -Rs .) + MESSAGE="${MESSAGE:1:-1}" # Remove surrounding quotes + + echo "Creating release ${NAME}..." + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg tag_name "${NAME}" \ + --arg name "${NAME}" \ + --arg body "${MESSAGE}" \ + --arg target "${DEFAULT_BRANCH}" \ + '{tag_name: $tag_name, name: $name, body: $body, target_commitish: $target}')" \ + "${API_URL}/releases" + + create-tag: + name: Create Tag + runs-on: ubuntu-latest + needs: prepare-release + if: | + github.ref_type == 'branch' && + github.ref_name == github.event.repository.default_branch && + inputs.tag_only == true + container: + image: amd64/alpine:3.22.2@sha256:b687e78c6e2785808446f45b52f1540a1e58adc07bdcffea354933b18c613d90 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install dependencies + run: apk add --no-cache git jq curl + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: release-artifacts + + - name: Create tag + env: + TOKEN: ${{ secrets.UNBOUND_RELEASE_TOKEN }} + REPOSITORY: ${{ github.repository }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ ! -r .version ]; then + echo "Version file not found" + exit 0 + fi + + VERSION=$(cat .version 2>/dev/null | jq -r '.version') + LATEST=$(git describe --abbrev=0 --tags 2>/dev/null || echo '') + + if [ -n "${LATEST}" ] && [ "${VERSION}" = "${LATEST}" ]; then + echo "Version ${VERSION} already exists" + exit 0 + fi + + OWNER=$(echo "${REPOSITORY}" | cut -d'/' -f1) + REPO=$(echo "${REPOSITORY}" | cut -d'/' -f2) + API_URL="${GITEA_URL}/api/v1/repos/${OWNER}/${REPO}" + + NAME=$(cat VERSION) + + echo "Creating tag ${NAME}..." + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg tag_name "${NAME}" \ + --arg target "${DEFAULT_BRANCH}" \ + --arg message "${NAME}" \ + '{tag_name: $tag_name, target: $target, message: $message}')" \ + "${API_URL}/tags" diff --git a/README.md b/README.md index 8b3a390..efbe18f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ -# shared-workflows +# Shared Workflows +Reusable Gitea Actions workflows for Unbound Software repositories. + +## Available Workflows + +### Release.yml + +Automated release workflow using [git-cliff](https://github.com/orhun/git-cliff) for changelog generation. + +**Usage:** + +```yaml +name: Release + +on: + push: + branches: [main] + +jobs: + release: + uses: unboundsoftware/shared-workflows/.gitea/workflows/Release.yml@main + secrets: + UNBOUND_RELEASE_TOKEN: ${{ secrets.GIT_API_TOKEN }} +``` + +**Inputs:** + +- `tag_only` (boolean, default: `false`): Set to `true` to only create tags without full releases + +**Secrets:** + +- `UNBOUND_RELEASE_TOKEN` (required): Token with API access to create PRs and releases. Required scopes: `repository` (read/write), `issue` (read/write) + +**How it works:** + +1. On each push to the default branch, generates a changelog using git-cliff +2. Creates or updates a `next-release` branch with the updated CHANGELOG.md and .version file +3. Opens or updates a PR titled "chore(release): prepare for vX.Y.Z" +4. When the .version file exists (after merging the release PR), creates a GitHub release with the changelog