Skip to content

SemVer Release

SemVer Release #219

name: SemVer Release
# Biweekly stable releases + manual urgent releases from master
# Normal commits to master only create dev releases (via publish-dev.yml)
on:
schedule:
# Every other Wednesday at 10:00 UTC (biweekly-gate skips odd weeks)
- cron: '0 10 * * 3'
workflow_dispatch:
inputs:
force:
description: 'Force a patch when no fix, feat, or perf commit exists'
required: false
default: 'false'
type: boolean
permissions:
contents: read
# Serialize with every other workflow that pushes to master (sync-tool-docs,
# addon-publish-dev, locale-sync). semantic-release's internal push aborts
# hard when origin/master moves mid-run ("Upstream branch ... has changed") and
# that abort is not retryable from inside the action, so the only safe fix is
# to keep master writers from overlapping. cancel-in-progress stays false: a
# queued release must run, not be cancelled.
concurrency:
group: master-write
cancel-in-progress: false
# queue: max keeps every queued run waiting FIFO. The default single-slot
# queue evicts the OLDER pending run when a new one queues, which could
# silently drop a queued release behind a bot push.
queue: max
env:
PYTHON_VERSION: "3.13"
jobs:
# Skip on odd ISO weeks (biweekly cadence) unless manually triggered
biweekly-gate:
name: Biweekly schedule gate
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.gate.outputs.should_run }}
steps:
- name: Check if this is a release week
id: gate
run: |
if [ "${{ github.event_name }}" != "schedule" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Manual or non-scheduled trigger, proceeding"
exit 0
fi
WEEK=$((10#$(date +%V)))
if [ $((WEEK % 2)) -eq 0 ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Even week ($WEEK), proceeding with release"
else
echo "should_run=false" >> $GITHUB_OUTPUT
echo "Odd week ($WEEK), skipping release"
fi
# Check for changes since last release (skip if no changes)
check-changes:
name: Check for releasable changes
needs: biweekly-gate
if: needs.biweekly-gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
fetch-depth: 0
- name: Check for releasable commits since last tag
id: check
run: |
# Get latest non-dev tag
LATEST_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*dev*' 2>/dev/null || echo "")
if [ -z "$LATEST_TAG" ]; then
echo "No previous release found, proceeding"
echo "has_changes=true" >> $GITHUB_OUTPUT
exit 0
fi
echo "Latest stable tag: $LATEST_TAG"
# Match every commit form that Python Semantic Release can bump.
CHANGES=$(git log $LATEST_TAG..HEAD --oneline -E \
--grep="^(feat|fix|perf|refactor)(\(.+\))?!?:" \
--grep="^[a-z]+(\(.+\))?!:" \
--grep="^BREAKING[ -]CHANGE:" | head -5)
if [ -n "$CHANGES" ] && [ "${{ inputs.force }}" = "true" ]; then
echo "::error::Force pins the next version to patch, but releasable commits exist:"
echo "$CHANGES"
echo "Dispatch again without force so semantic-release selects the correct level."
exit 1
fi
if [ -n "$CHANGES" ] || [ "${{ inputs.force }}" = "true" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "Changes found:"
echo "$CHANGES"
else
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No releasable changes since $LATEST_TAG"
fi
# Validate Docker build before committing to a release
validate-docker:
name: Validate Docker Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
submodules: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Build addon image (no push)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: ./homeassistant-addon/Dockerfile
push: false
platforms: linux/amd64
# Semantic versioning and release creation
semantic-release:
needs: [check-changes, validate-docker]
if: needs.check-changes.outputs.has_changes == 'true'
# This display name is load-bearing: sync-integration-mirror.yml's gate
# matches the triggering run's jobs by this exact name to decide whether a
# release happened. Rename it together with that gate's matcher.
name: Semantic Release
runs-on: ubuntu-latest
outputs:
released: ${{ steps.semantic.outputs.released }}
version: ${{ steps.semantic.outputs.version }}
permissions:
contents: write # Required to push commits and tags
id-token: write # Required for trusted publishing
pull-requests: write # Required to create pull requests
issues: write # Required for GitHub releases
steps:
# Generate GitHub App token with bypass permissions for tag creation
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
permission-contents: write
# Fallback to RELEASE_TOKEN (PAT) or GITHUB_TOKEN if app credentials not configured
continue-on-error: true
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
# Priority: GitHub App token > RELEASE_TOKEN (PAT) > GITHUB_TOKEN
token: ${{ steps.app-token.outputs.token || secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
- name: Python Semantic Release
id: semantic
uses: python-semantic-release/python-semantic-release@39dd2052f2ce8282a5d932c31d58a2ca06d2550e # v10.6.1
with:
# Use GitHub App token with bypass permissions
github_token: ${{ steps.app-token.outputs.token || secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
verbosity: "2"
# Force pins the bump to patch and is rejected above when releasable
# commits exist, so it cannot override a required minor/major bump.
force: ${{ inputs.force && 'patch' || '' }}
# Don't create GitHub release here - we'll create a draft below
vcs_release: "false"
- name: Create draft GitHub release
if: steps.semantic.outputs.released == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.semantic.outputs.version }}"
TAG="v${VERSION}"
# Extract changelog for this version, capped at GitHub's release-body limit
# (an over-long body is a 422 that strands the already-pushed tag).
python3 scripts/extract_release_notes.py --version "$VERSION" --out release_notes.md
if [ ! -s release_notes.md ]; then
# An empty extract means either no "## v${VERSION}" heading or an
# empty section, and the file cannot tell those apart — so warn
# instead of letting a one-line release body pass for a normal run.
echo "::warning::no changelog section extracted for v${VERSION} — releasing with fallback notes"
echo "Release v${VERSION}" > release_notes.md
fi
# Create draft release (build-binary.yml will add binaries and publish)
gh release create "$TAG" \
--title "$TAG" \
--notes-file release_notes.md \
--draft
echo "✓ Created draft release $TAG (waiting for binaries)"
- name: Copy changelog to addon directory
if: steps.semantic.outputs.released == 'true'
run: |
set -e
cp CHANGELOG.md homeassistant-addon/CHANGELOG.md
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add homeassistant-addon/CHANGELOG.md
git diff --staged --quiet || git commit -m "chore(addon): sync changelog for Home Assistant add-on [skip ci]"
# Master can advance between checkout and this push (bot commits from
# other workflows); retry with a rebase instead of failing the release.
# Mirrors the loop in sync-tool-docs.yml.
for attempt in 1 2 3 4 5; do
if git push; then
echo "Synced addon changelog (attempt ${attempt})."
exit 0
fi
echo "Push rejected on attempt ${attempt}; rebasing onto origin/master and retrying."
# Abort a conflicted rebase so later attempts fail for the real reason.
git pull --rebase origin master || {
git rebase --abort 2>/dev/null || true
echo "::error::Rebase onto origin/master failed; aborting retries."
exit 1
}
done
echo "::error::Could not push addon changelog sync after 5 attempts."
exit 1
- name: Update stable git tag
if: steps.semantic.outputs.released == 'true'
run: |
# Move the 'stable' tag to point to the new release
VERSION="${{ steps.semantic.outputs.version }}"
echo "Updating 'stable' tag to point to v$VERSION"
# Delete existing stable tag (local and remote)
git tag -d stable 2>/dev/null || true
git push origin :refs/tags/stable 2>/dev/null || true
# Create new stable tag pointing to the release commit
git tag stable "v$VERSION"
git push origin stable
echo "✓ 'stable' tag now points to v$VERSION"
# Build binaries and attach to release
build-and-release:
needs: semantic-release
if: needs.semantic-release.outputs.released == 'true'
uses: ./.github/workflows/_build-and-release.yml
with:
release_tag: v${{ needs.semantic-release.outputs.version }}
is_prerelease: false
permissions:
contents: write
# Build and push addon Docker images
build-addon:
needs: semantic-release
if: needs.semantic-release.outputs.released == 'true'
uses: ./.github/workflows/addon-publish.yml
with:
version: ${{ needs.semantic-release.outputs.version }}
permissions:
contents: read
packages: write
# Update addon config.yaml after images are published
update-addon-config:
needs: [semantic-release, build-addon]
if: needs.semantic-release.outputs.released == 'true'
uses: ./.github/workflows/_update-addon-config.yml
with:
version: ${{ needs.semantic-release.outputs.version }}
secrets:
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
permissions:
contents: write