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 concurrency: group: release-${{ github.repository }} cancel-in-progress: false env: GITEA_URL: http://gitea-http.gitea.svc.cluster.local:3000 RELEASE_TOKEN_FILE: /runner-secrets/release-token GIT_CLIFF_VERSION: "2.13.1" jobs: preconditions: name: Check Preconditions runs-on: ubuntu-latest steps: - name: Validate token run: | if [ ! -r "${RELEASE_TOKEN_FILE}" ]; then echo "Release token file not found at ${RELEASE_TOKEN_FILE}" echo "This workflow requires the runner to have RELEASE_TOKEN configured." exit 1 fi if [ ! -s "${RELEASE_TOKEN_FILE}" ]; then echo "Release token file is empty" exit 1 fi echo "Release token found" changelog-and-pr: name: Generate Changelog and Handle PR runs-on: ubuntu-latest needs: preconditions if: github.ref_type == 'branch' && github.ref_name == github.event.repository.default_branch steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install git-cliff run: | curl -sSfL "https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar xz sudo mv "git-cliff-${GIT_CLIFF_VERSION}/git-cliff" /usr/local/bin/ git-cliff --version - 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: Create or update release PR if: steps.check.outputs.has_changes == 'true' env: REPOSITORY: ${{ github.repository }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | TOKEN=$(cat "${RELEASE_TOKEN_FILE}") VERSION=$(cat VERSION) OWNER=$(echo "${REPOSITORY}" | cut -d'/' -f1) REPO=$(echo "${REPOSITORY}" | cut -d'/' -f2) API_URL="${GITEA_URL}/api/v1/repos/${OWNER}/${REPO}" BASE_BRANCH="${DEFAULT_BRANCH:-main}" echo "Using base branch: ${BASE_BRANCH}" TITLE="chore(release): prepare for ${VERSION}" CHANGES_CONTENT=$(cat CHANGES.md) PR_NOTE="**Note:** Please use **Squash Merge** when merging this PR." DESCRIPTION="${CHANGES_CONTENT}"$'\n\n---\n\n'"${PR_NOTE}" CHANGELOG_CONTENT=$(base64 -w0 < CHANGELOG.md) VERSION_CONTENT=$(jq -n --arg v "${VERSION}" '{"version":$v}' | base64 -w0) # api_call METHOD PATH [JSON_BODY] # Stdout: first line "|", then response body. # Never returns non-zero so callers must inspect http_code; this # prevents curl exit codes (e.g. CURLE_WRITE_ERROR / 23) from # killing the script via `set -e` inside command substitutions. api_call() { local method="$1" path="$2" data="${3:-}" local body_file http_code rc=0 body_file=$(mktemp) local args=(-sS --retry 3 --retry-delay 2 --retry-all-errors -w '%{http_code}' -o "${body_file}" -X "${method}" -H "Authorization: token ${TOKEN}") if [ -n "${data}" ]; then args+=(-H "Content-Type: application/json" --data "${data}") fi http_code=$(curl "${args[@]}" "${API_URL}${path}" 2>/dev/null) || rc=$? printf '%s|%s\n' "${http_code:-000}" "${rc}" cat "${body_file}" rm -f "${body_file}" return 0 } # Extract first-line meta and remaining body from api_call output. meta_line() { printf '%s\n' "$1" | head -n1; } body_lines() { printf '%s\n' "$1" | tail -n +2; } http_of() { local m; m=$(meta_line "$1"); printf '%s' "${m%%|*}"; } ok_code() { [ -n "$1" ] && [ "$1" -ge 200 ] 2>/dev/null && [ "$1" -lt 400 ]; } # Delete existing next-release branch if it exists (auto-closes any open PR) echo "Checking for existing next-release branch..." OUT=$(api_call GET "/branches/next-release") CODE=$(http_of "${OUT}") if [ "${CODE}" = "200" ]; then echo "Deleting existing next-release branch..." OUT=$(api_call DELETE "/branches/next-release") echo " delete result: $(meta_line "${OUT}")" else echo " no existing branch (HTTP ${CODE})" fi # Explicitly create next-release branch from base echo "Creating next-release branch from ${BASE_BRANCH}..." BRANCH_PAYLOAD=$(jq -n --arg new "next-release" --arg old "${BASE_BRANCH}" \ '{new_branch_name: $new, old_branch_name: $old}') for i in $(seq 1 5); do OUT=$(api_call POST "/branches" "${BRANCH_PAYLOAD}") META=$(meta_line "${OUT}"); BODY=$(body_lines "${OUT}"); CODE="${META%%|*}" if ok_code "${CODE}"; then echo "Branch created (${META})" break fi if [ "${i}" = "5" ]; then echo "Branch create failed after 5 attempts (${META}): ${BODY}" exit 1 fi echo " attempt ${i}/5 (${META}): ${BODY} — retrying..." sleep 3 done # Poll until branch is readable echo "Waiting for branch readiness..." for i in $(seq 1 10); do OUT=$(api_call GET "/branches/next-release") META=$(meta_line "${OUT}"); CODE="${META%%|*}" if [ "${CODE}" = "200" ]; then echo "Branch ready after ${i} attempt(s)" break fi if [ "${i}" = "10" ]; then echo "Branch not ready after 10 attempts (last: ${META})" exit 1 fi echo " attempt ${i}/10 (${META}) — waiting..." sleep 2 done # Fetch file blob SHA from BASE_BRANCH. next-release was just forked # from base so the blob SHA matches; querying base avoids racing # Gitea's per-endpoint propagation for the new branch (the /contents # endpoint can still 500/404 after /branches reports 200). Returns # empty only when the file genuinely does not exist on base. fetch_sha() { local path="$1" out meta code body for i in $(seq 1 5); do out=$(api_call GET "/contents/${path}?ref=${BASE_BRANCH}") meta=$(meta_line "${out}"); code="${meta%%|*}"; body=$(body_lines "${out}") if [ "${code}" = "200" ]; then printf '%s' "${body}" | jq -r '.sha // empty' return 0 fi if [ "${code}" = "404" ]; then return 0 fi if [ "${i}" = "5" ]; then echo "fetch_sha ${path} failed after 5 attempts (${meta}): ${body}" >&2 return 0 fi sleep 2 done } CHANGELOG_SHA=$(fetch_sha "CHANGELOG.md") VERSION_SHA=$(fetch_sha ".version") # Write file with retry. Args: PATH CONTENT_B64 [SHA] write_file() { local path="$1" content="$2" sha="${3:-}" local method payload out meta body code if [ -n "${sha}" ]; then method=PUT payload=$(jq -n \ --arg content "${content}" \ --arg sha "${sha}" \ --arg message "${TITLE}" \ --arg branch "next-release" \ '{content: $content, sha: $sha, message: $message, branch: $branch}') else method=POST payload=$(jq -n \ --arg content "${content}" \ --arg message "${TITLE}" \ --arg branch "next-release" \ '{content: $content, message: $message, branch: $branch}') fi for i in $(seq 1 5); do out=$(api_call "${method}" "/contents/${path}" "${payload}") meta=$(meta_line "${out}"); body=$(body_lines "${out}"); code="${meta%%|*}" if ok_code "${code}"; then echo "${path} write succeeded (${meta})" return 0 fi if [ "${i}" = "5" ]; then echo "${path} write failed after 5 attempts (${meta}): ${body}" return 1 fi echo " ${path} attempt ${i}/5 (${meta}): ${body} — retrying..." sleep 3 done } echo "Writing CHANGELOG.md to next-release..." write_file "CHANGELOG.md" "${CHANGELOG_CONTENT}" "${CHANGELOG_SHA}" echo "Writing .version to next-release..." write_file ".version" "${VERSION_CONTENT}" "${VERSION_SHA}" # Create PR echo "Creating PR..." PR_DATA=$(jq -n \ --arg title "${TITLE}" \ --arg body "${DESCRIPTION}" \ --arg head "next-release" \ --arg base "${BASE_BRANCH}" \ '{title: $title, body: $body, head: $head, base: $base}') for i in $(seq 1 5); do OUT=$(api_call POST "/pulls" "${PR_DATA}") META=$(meta_line "${OUT}"); BODY=$(body_lines "${OUT}"); CODE="${META%%|*}" if ok_code "${CODE}"; then echo "PR created (${META})" break fi if [ "${i}" = "5" ]; then echo "PR creation failed after 5 attempts (${META}): ${BODY}" exit 1 fi echo " PR attempt ${i}/5 (${META}): ${BODY} — retrying..." sleep 3 done create-release: name: Create Release runs-on: ubuntu-latest needs: preconditions if: | github.ref_type == 'branch' && github.ref_name == github.event.repository.default_branch && inputs.tag_only != true steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install git-cliff run: | curl -sSfL "https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar xz sudo mv "git-cliff-${GIT_CLIFF_VERSION}/git-cliff" /usr/local/bin/ git-cliff --version - name: Generate changelog run: git-cliff --bump --unreleased --strip header > CHANGES.md - name: Get version id: version run: | VERSION=$(git-cliff --bumped-version 2>/dev/null || echo "") echo "version=${VERSION}" >> $GITHUB_OUTPUT - name: Create release env: REPOSITORY: ${{ github.repository }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} VERSION: ${{ steps.version.outputs.version }} run: | TOKEN=$(cat "${RELEASE_TOKEN_FILE}") if [ ! -r .version ]; then echo "Version file not found" exit 0 fi CURRENT_VERSION=$(cat .version 2>/dev/null | jq -r '.version') LATEST=$(git describe --abbrev=0 --tags 2>/dev/null || echo '') if [ -n "${LATEST}" ] && [ "${CURRENT_VERSION}" = "${LATEST}" ]; then echo "Version ${CURRENT_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}" MESSAGE=$(cat CHANGES.md) echo "Creating release ${VERSION}..." curl -sf --retry 3 --retry-delay 2 --retry-connrefused -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ --data "$(jq -n \ --arg tag_name "${VERSION}" \ --arg name "${VERSION}" \ --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: preconditions if: | github.ref_type == 'branch' && github.ref_name == github.event.repository.default_branch && inputs.tag_only == true steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install git-cliff run: | curl -sSfL "https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar xz sudo mv "git-cliff-${GIT_CLIFF_VERSION}/git-cliff" /usr/local/bin/ git-cliff --version - name: Get version id: version run: | VERSION=$(git-cliff --bumped-version 2>/dev/null || echo "") echo "version=${VERSION}" >> $GITHUB_OUTPUT - name: Create tag env: REPOSITORY: ${{ github.repository }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} VERSION: ${{ steps.version.outputs.version }} run: | TOKEN=$(cat "${RELEASE_TOKEN_FILE}") if [ ! -r .version ]; then echo "Version file not found" exit 0 fi CURRENT_VERSION=$(cat .version 2>/dev/null | jq -r '.version') LATEST=$(git describe --abbrev=0 --tags 2>/dev/null || echo '') if [ -n "${LATEST}" ] && [ "${CURRENT_VERSION}" = "${LATEST}" ]; then echo "Version ${CURRENT_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}" echo "Creating tag ${VERSION}..." curl -sf --retry 3 --retry-delay 2 --retry-connrefused -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ --data "$(jq -n \ --arg tag_name "${VERSION}" \ --arg target "${DEFAULT_BRANCH}" \ --arg message "${VERSION}" \ '{tag_name: $tag_name, target: $target, message: $message}')" \ "${API_URL}/tags"