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"Shared (cross-cutting)·Reusable workflow·on: workflow_call
Shared Release
Shared - Create Release Branch
.github/workflows/shared-release.yml