e07c8412f0
After POST /branches reports 201 and GET /branches/next-release reports
200, the /contents/{path}?ref=next-release endpoint can still 500 or
404 transiently while Gitea finishes indexing the new branch. That
caused fetch_sha to return empty for files that actually existed on
base, so write_file fell back to POST (create) and got HTTP 422
"repository file already exists" five times before giving up.
Query base branch for the blob SHA instead. Base is stable, and Gitea
content writes are content-addressed by blob SHA, so a SHA fetched from
main works for PUT on next-release (next-release was just forked from
main, so the blob is identical). Treat 404 as "file absent" and retry
other non-200 responses up to 5 times.
396 lines
15 KiB
YAML
396 lines
15 KiB
YAML
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@v6
|
|
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 "<http_code>|<curl_rc>", 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@v6
|
|
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@v6
|
|
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"
|