Saltar al contenido
mypipelines
Shared (cross-cutting)·Reusable workflow·on: workflow_call

Shared Release

Shared - Create Release Branch

.github/workflows/shared-release.yml

.github/workflows/shared-release.yml
name: Shared - Create Release Branch
# Simple release-branch flow (triggered on merge to develop):
# develop --(semver)--> creates branch release/vX.Y.Z (pushed, targets main)
# --> opens a PR release/vX.Y.Z -> main with the changelog in the body
# Merging that PR to main deploys to CERT; production is reached only by a MANUAL semver
# tag vX.Y.Z (see tag-deploy / tag-pipeline).
#
# Required caller permissions:
# contents: write # create/push the release/vX.Y.Z branch
# pull-requests: write # open the release PR
on:
workflow_call:
inputs:
base_branch:
description: 'Source branch the release is cut from (semver source)'
required: false
type: string
default: 'develop'
target_branch:
description: 'Branch the release is intended for (used to detect "anything to release")'
required: false
type: string
default: 'main'
strict_flow:
description: 'Enforce GitFlow: base_branch must be develop, target_branch must be main'
required: false
type: boolean
default: true
outputs:
version:
description: 'Computed release version (semver)'
value: ${{ jobs.release.outputs.version }}
release_branch:
description: 'Release branch created (release/vX.Y.Z)'
value: ${{ jobs.release.outputs.release_branch }}
pr_url:
description: 'Release PR URL (release/vX.Y.Z -> target_branch)'
value: ${{ jobs.release.outputs.pr_url }}
changelog:
description: 'Changelog (commit subjects) included in the release PR'
value: ${{ jobs.release.outputs.changelog }}
# Least privilege for what this workflow actually does. Without it the
# job inherits whatever the calling repository's default happens to be,
# which on older repositories is write-all.
permissions:
contents: write
concurrency:
group: shared-release-${{ github.repository }}-${{ inputs.base_branch }}
cancel-in-progress: false
jobs:
release:
name: Create Release Branch
runs-on: ubuntu-latest
# Branch, semver and PR create; a hung step should not hold the runner for
# GitHub's six-hour default in every consumer at once.
timeout-minutes: 10
outputs:
version: ${{ steps.semver.outputs.version }}
release_branch: ${{ steps.branch.outputs.release_branch }}
pr_url: ${{ steps.pr.outputs.pr_url }}
changelog: ${{ steps.changelog.outputs.changelog }}
steps:
- name: Validate release flow
env:
BASE_BRANCH: ${{ inputs.base_branch }}
TARGET_BRANCH: ${{ inputs.target_branch }}
STRICT_FLOW: ${{ inputs.strict_flow }}
run: |
echo "Release flow: $BASE_BRANCH -> release/vX.Y.Z (intended for $TARGET_BRANCH, strict=$STRICT_FLOW)"
if [ "$STRICT_FLOW" != "true" ]; then
echo "Strict flow disabled, skipping validation."
exit 0
fi
ERRORS=0
if [ "$BASE_BRANCH" != "develop" ]; then
echo "::error::Release base_branch must be 'develop' under strict flow. Got: '$BASE_BRANCH'."
ERRORS=1
fi
if [ "$TARGET_BRANCH" != "main" ]; then
echo "::error::Release target_branch must be 'main' under strict flow. Got: '$TARGET_BRANCH'."
ERRORS=1
fi
[ "$ERRORS" -eq 0 ] || { echo "Strict GitFlow requires develop -> main."; exit 1; }
echo "Release flow validated."
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.base_branch }}
fetch-depth: 0
persist-credentials: true
- name: Fetch all tags
run: git fetch --tags --force
- name: Calculate semantic version
id: semver
run: |
LAST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -n1)
if [ -z "$LAST_TAG" ]; then
LAST_TAG="v0.0.0"
echo "No previous tag found, starting from $LAST_TAG"
else
echo "Last tag: $LAST_TAG"
fi
LAST_VERSION="${LAST_TAG#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$LAST_VERSION"
if git rev-parse "$LAST_TAG" >/dev/null 2>&1; then
COMMITS=$(git log "${LAST_TAG}..HEAD" --pretty=format:'%s%n%b')
else
COMMITS=$(git log --pretty=format:'%s%n%b')
fi
if [ -z "$COMMITS" ]; then
echo "No new commits since $LAST_TAG"
VERSION="${MAJOR}.${MINOR}.${PATCH}"
elif echo "$COMMITS" | grep -qE '(MAJOR|BREAKING CHANGE)'; then
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0
VERSION="${MAJOR}.${MINOR}.${PATCH}"
elif echo "$COMMITS" | grep -qE '^feat(\(|:|!)'; then
MINOR=$((MINOR + 1)); PATCH=0
VERSION="${MAJOR}.${MINOR}.${PATCH}"
else
PATCH=$((PATCH + 1))
VERSION="${MAJOR}.${MINOR}.${PATCH}"
fi
echo "Calculated version: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Guard against already-released version
id: guard
env:
VERSION: ${{ steps.semver.outputs.version }}
run: |
TAG="v${VERSION}"
if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::warning::Tag ${TAG} already exists; this version was already released. Skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check branches diverged
id: diff
if: steps.guard.outputs.skip == 'false'
env:
BASE_BRANCH: ${{ inputs.base_branch }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
git fetch --no-tags origin "${BASE_BRANCH}" "${TARGET_BRANCH}"
AHEAD="$(git rev-list --count "origin/${TARGET_BRANCH}..origin/${BASE_BRANCH}")"
if [ "${AHEAD}" -eq 0 ]; then
echo "::warning::${BASE_BRANCH} has no commits ahead of ${TARGET_BRANCH}; nothing to release."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Create release branch
id: branch
if: steps.guard.outputs.skip == 'false' && steps.diff.outputs.skip == 'false'
env:
VERSION: ${{ steps.semver.outputs.version }}
BASE_BRANCH: ${{ inputs.base_branch }}
run: |
RELEASE_BRANCH="release/v${VERSION}"
echo "release_branch=${RELEASE_BRANCH}" >> "$GITHUB_OUTPUT"
if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then
echo "::notice::Branch ${RELEASE_BRANCH} already exists; leaving it as-is."
else
git push origin "origin/${BASE_BRANCH}:refs/heads/${RELEASE_BRANCH}"
echo "::notice::Created ${RELEASE_BRANCH} from ${BASE_BRANCH}."
fi
- name: Generate changelog
id: changelog
if: steps.guard.outputs.skip == 'false' && steps.diff.outputs.skip == 'false'
env:
BASE_BRANCH: ${{ inputs.base_branch }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
{
echo "changelog<<CHANGELOG_EOF"
git log --pretty=format:'- %s (%h)' "origin/${TARGET_BRANCH}..origin/${BASE_BRANCH}"
echo ""
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- name: Open release PR
id: pr
if: steps.guard.outputs.skip == 'false' && steps.diff.outputs.skip == 'false'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.semver.outputs.version }}
RELEASE_BRANCH: ${{ steps.branch.outputs.release_branch }}
TARGET_BRANCH: ${{ inputs.target_branch }}
CHANGELOG: ${{ steps.changelog.outputs.changelog }}
run: |
EXISTING_PR="$(gh pr list --state open --head "${RELEASE_BRANCH}" --base "${TARGET_BRANCH}" --json url --jq '.[0].url // empty')"
if [ -n "${EXISTING_PR}" ]; then
echo "::notice::Release PR already open: ${EXISTING_PR}"
echo "pr_url=${EXISTING_PR}" >> "$GITHUB_OUTPUT"
else
printf '## Changelog\n\n%s\n' "${CHANGELOG}" > /tmp/release-pr-body.md
PR_URL="$(gh pr create \
--title "Release v${VERSION}" \
--base "${TARGET_BRANCH}" \
--head "${RELEASE_BRANCH}" \
--body-file /tmp/release-pr-body.md)"
echo "::notice::Created release PR: ${PR_URL}"
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
fi
- name: Write summary
if: steps.guard.outputs.skip == 'false' && steps.diff.outputs.skip == 'false'
env:
VERSION: ${{ steps.semver.outputs.version }}
RELEASE_BRANCH: ${{ steps.branch.outputs.release_branch }}
PR_URL: ${{ steps.pr.outputs.pr_url }}
run: |
{
echo "### Release branch"
echo ""
echo "| Property | Value |"
echo "|----------|-------|"
echo "| Version | v${VERSION} |"
echo "| Branch | ${RELEASE_BRANCH} |"
echo "| From | ${{ inputs.base_branch }} |"
echo "| Target | ${{ inputs.target_branch }} |"
echo "| PR | ${PR_URL} |"
} >> "$GITHUB_STEP_SUMMARY"