SemVer Release #209
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: SemVer Release | |
| # Biweekly stable releases + manual trigger + hotfix branches | |
| # 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 release even without changes' | |
| required: false | |
| default: 'false' | |
| type: boolean | |
| permissions: | |
| contents: read | |
| # Serialize with every other workflow that pushes to master (sync-tool-docs, | |
| # addon-publish-dev, hotfix-release). 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@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Check for feat/fix 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" | |
| # Check for feat/fix commits since last tag | |
| CHANGES=$(git log $LATEST_TAG..HEAD --oneline --grep="^feat" --grep="^fix" --grep="^perf" | head -5) | |
| 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@v7 | |
| with: | |
| 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@v3 | |
| with: | |
| app-id: ${{ secrets.RELEASE_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} | |
| # Fallback to RELEASE_TOKEN (PAT) or GITHUB_TOKEN if app credentials not configured | |
| continue-on-error: true | |
| - uses: actions/checkout@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" | |
| # 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 (content between version headers) | |
| awk -v ver="## v${VERSION}" '$0 ~ "^"ver {found=1; next} found && /^## v[0-9]/ {exit} found' CHANGELOG.md > release_notes.md | |
| if [ ! -s release_notes.md ]; then | |
| 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 |