Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

<!-- Brief description of your changes -->

## Release notes

<!--
Required when this PR bumps custom_components/esphome_mcp/manifest.json.
Replace this comment with concise, user-facing Markdown that describes what
changed. This section is published verbatim under "What's changed" in the
GitHub release. Do not use "N/A", "None", or another placeholder for a release.
-->

## Type of change

- [ ] Bug fix
Expand Down
24 changes: 22 additions & 2 deletions .github/workflows/e2e-component.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,35 @@ jobs:
echo "Failed to parse ESPHome Device Builder add-on version" >&2
exit 1
fi
hacs_addon_config="$(
gh api \
-H "Accept: application/vnd.github.raw" \
repos/hacs/addons/contents/get/config.yaml
)"
hacs_addon_hash="$(
printf '%s' "$hacs_addon_config" | sha256sum | cut -d' ' -f1 | head -c16
)"
hacs_version="$(gh api repos/hacs/integration/releases/latest --jq '.tag_name')"
if [ -z "$hacs_version" ]; then
echo "Failed to resolve the latest HACS release" >&2
exit 1
fi
hash=$(git ls-tree -r HEAD \
tests/haos_image_build \
tests/initial_test_state \
custom_components/esphome_mcp \
| { cat; echo "esphome-addon:$esphome_addon_hash"; } \
| {
cat
echo "esphome-addon:$esphome_addon_hash"
echo "hacs-addon:$hacs_addon_hash"
echo "hacs-release:$hacs_version"
} \
| sha256sum | cut -d' ' -f1 | head -c16)
echo "cache-key=esphome-mcp-haos-image-$hash" >> "$GITHUB_OUTPUT"
echo "esphome-addon-hash=$esphome_addon_hash" >> "$GITHUB_OUTPUT"
echo "esphome-version=$esphome_version" >> "$GITHUB_OUTPUT"
echo "hacs-addon-hash=$hacs_addon_hash" >> "$GITHUB_OUTPUT"
echo "hacs-version=$hacs_version" >> "$GITHUB_OUTPUT"

- name: Restore image from cache
id: restore-cache
Expand All @@ -72,7 +92,7 @@ jobs:

- name: Report image acquisition path
run: |
echo "::notice title=HAOS image cache::cache-hit=${{ steps.restore-cache.outputs.cache-hit }} key=${{ steps.key.outputs.cache-key }} esphome-addon-hash=${{ steps.key.outputs.esphome-addon-hash }} esphome-version=${{ steps.key.outputs.esphome-version }}"
echo "::notice title=HAOS image cache::cache-hit=${{ steps.restore-cache.outputs.cache-hit }} key=${{ steps.key.outputs.cache-key }} esphome-addon-hash=${{ steps.key.outputs.esphome-addon-hash }} esphome-version=${{ steps.key.outputs.esphome-version }} hacs-addon-hash=${{ steps.key.outputs.hacs-addon-hash }} hacs-version=${{ steps.key.outputs.hacs-version }}"

- name: Install QEMU, OVMF, and libguestfs
run: |
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/release-notes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Release Notes

on:
pull_request:
branches: [master]
types: [opened, edited, reopened, synchronize]

permissions:
contents: read

jobs:
release-notes:
name: Release Notes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Validate release notes for version bumps
run: >-
python scripts/release_notes.py validate-pr
--base-ref "origin/${{ github.base_ref }}"
--event-path "$GITHUB_EVENT_PATH"
30 changes: 29 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ jobs:
if: ${{ github.ref_name == 'master' && github.event.inputs.dry_run != 'true' }}
permissions:
contents: write
pull-requests: read
steps:
- name: Inspect existing release and tag
id: release-state
Expand Down Expand Up @@ -136,6 +137,33 @@ jobs:
exit 1
fi

- name: Check out release source
if: steps.release-state.outputs.should_publish == 'true'
uses: actions/checkout@v7

- name: Set up Python
if: steps.release-state.outputs.should_publish == 'true'
uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: Build release notes from merged pull request
if: steps.release-state.outputs.should_publish == 'true'
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
"repos/${GH_REPO}/commits/${GITHUB_SHA}/pulls" \
> /tmp/release-pulls.json
python scripts/release_notes.py render \
--pulls-json /tmp/release-pulls.json \
--sha "${GITHUB_SHA}" \
--output /tmp/release-notes.md

- name: Create GitHub release
if: steps.release-state.outputs.should_publish == 'true'
env:
Expand All @@ -149,4 +177,4 @@ jobs:
gh release create "${tag}" \
--target "${GITHUB_SHA}" \
--title "${tag}" \
--notes "Release ${tag} for HACS installation."
--notes-file /tmp/release-notes.md
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Builder tools need Supervisor.

This repository is release-backed for HACS installs. The release workflow
publishes the component manifest version as a GitHub Release tag such as
`v0.1.3`, which is the version HACS displays. Do not install a
`v0.1.4`, which is the version HACS displays. Do not install a
seven-character commit version such as `99cdab0`. If HACS has cached an old
commit-only entry, refresh the custom repository before installing.

Expand Down
2 changes: 1 addition & 1 deletion custom_components/esphome_mcp/const.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Constants for the ESPHome MCP custom component."""

DOMAIN = "esphome_mcp"
VERSION = "0.1.3"
VERSION = "0.1.4"

DEFAULT_SERVER_PORT = 9590
DEFAULT_BIND_HOST = "0.0.0.0"
Expand Down
2 changes: 1 addition & 1 deletion custom_components/esphome_mcp/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"documentation": "https://github.com/kingpanther13/esphome-mcp",
"iot_class": "local_push",
"issue_tracker": "https://github.com/kingpanther13/esphome-mcp/issues",
"version": "0.1.3"
"version": "0.1.4"
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "esphome-mcp-custom-component"
version = "0.1.3"
version = "0.1.4"
description = "ESPHome MCP server as a Home Assistant custom component"
readme = "README.md"
requires-python = ">=3.13"
Expand Down
203 changes: 203 additions & 0 deletions scripts/release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Validate and render GitHub release notes from a merged pull request."""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = "custom_components/esphome_mcp/manifest.json"

_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
_RELEASE_HEADING_RE = re.compile(r"^##[ \t]+release notes[ \t]*$", re.IGNORECASE)
_TOP_LEVEL_HEADING_RE = re.compile(r"^#{1,2}(?:[ \t]+|$)")
_FENCE_RE = re.compile(r"^[ \t]*(`{3,}|~{3,})")
_EMPTY_RELEASE_NOTES = {
"n a",
"na",
"no release notes",
"none",
"not applicable",
}


class ReleaseNotesError(ValueError):
"""Raised when release notes cannot be safely produced."""


def _fence_marker(line: str) -> str | None:
match = _FENCE_RE.match(line)
return match.group(1) if match else None


def extract_release_notes(body: str) -> str:
"""Extract the single level-two Release notes section from a PR body."""
cleaned = _HTML_COMMENT_RE.sub("", body)
lines = cleaned.splitlines()
section_starts: list[int] = []
active_fence: str | None = None

for index, line in enumerate(lines):
marker = _fence_marker(line)
if marker is not None:
if active_fence is None:
active_fence = marker
elif marker[0] == active_fence[0] and len(marker) >= len(active_fence):
active_fence = None
continue
if active_fence is None and _RELEASE_HEADING_RE.fullmatch(line.strip()):
section_starts.append(index)

if not section_starts:
raise ReleaseNotesError("pull request body is missing a '## Release notes' section")
if len(section_starts) > 1:
raise ReleaseNotesError("pull request body contains multiple '## Release notes' sections")

start = section_starts[0] + 1
end = len(lines)
active_fence = None
for index in range(start, len(lines)):
marker = _fence_marker(lines[index])
if marker is not None:
if active_fence is None:
active_fence = marker
elif marker[0] == active_fence[0] and len(marker) >= len(active_fence):
active_fence = None
continue
if active_fence is None and _TOP_LEVEL_HEADING_RE.match(lines[index].strip()):
end = index
break

notes = "\n".join(lines[start:end]).strip()
if not notes:
raise ReleaseNotesError("the '## Release notes' section is empty")

normalized = re.sub(r"[^a-z0-9]+", " ", notes.casefold()).strip()
if normalized in _EMPTY_RELEASE_NOTES:
raise ReleaseNotesError("the '## Release notes' section must describe what changed")
return notes


def select_merged_pull(pulls: Any, merge_sha: str) -> dict[str, Any]:
"""Select the merged PR that introduced the release target commit."""
if not isinstance(pulls, list):
raise ReleaseNotesError("commit-to-pull response must be a JSON list")

merged = [pull for pull in pulls if isinstance(pull, dict) and pull.get("merged_at")]
exact_matches = [pull for pull in merged if pull.get("merge_commit_sha") == merge_sha]
if len(exact_matches) == 1:
return exact_matches[0]
if len(exact_matches) > 1:
raise ReleaseNotesError(f"multiple merged pull requests claim release commit {merge_sha}")

# GitHub can rewrite commit SHAs for rebase merges. The endpoint itself is
# commit-specific, so one merged result remains unambiguous in that case.
if len(merged) == 1:
return merged[0]
if not merged:
raise ReleaseNotesError(
f"no merged pull request is associated with release commit {merge_sha}"
)
raise ReleaseNotesError(
f"multiple merged pull requests are associated with release commit {merge_sha}"
)


def render_release_notes(pulls: Any, merge_sha: str) -> str:
"""Render a GitHub release body from the exact merged PR."""
pull = select_merged_pull(pulls, merge_sha)
number = pull.get("number")
title = pull.get("title")
url = pull.get("html_url")
body = pull.get("body")

if not isinstance(number, int):
raise ReleaseNotesError("merged pull request is missing its number")
if not isinstance(title, str) or not title.strip():
raise ReleaseNotesError("merged pull request is missing its title")
if not isinstance(url, str) or not url.startswith("https://github.com/"):
raise ReleaseNotesError("merged pull request is missing its GitHub URL")
if not isinstance(body, str):
raise ReleaseNotesError("merged pull request body is empty")

notes = extract_release_notes(body)
return (
f"## What's changed\n\n{notes}\n\n---\n\n[Pull request #{number}]({url}): {title.strip()}\n"
)


def _git(args: list[str]) -> str:
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()


def _manifest_version_from_worktree() -> str:
manifest = json.loads((ROOT / MANIFEST_PATH).read_text())
return str(manifest["version"])


def _manifest_version_from_ref(ref: str) -> str:
manifest = json.loads(_git(["show", f"{ref}:{MANIFEST_PATH}"]))
return str(manifest["version"])


def release_version_changed(base_ref: str) -> bool:
"""Return whether this PR changes the version that will be released."""
return _manifest_version_from_worktree() != _manifest_version_from_ref(base_ref)


def validate_pull_request_event(event_path: Path, base_ref: str) -> bool:
"""Validate release notes when a PR changes the release version."""
if not release_version_changed(base_ref):
return False

event = json.loads(event_path.read_text())
pull_request = event.get("pull_request")
if not isinstance(pull_request, dict):
raise ReleaseNotesError("GitHub event does not contain a pull_request object")
body = pull_request.get("body")
extract_release_notes(body if isinstance(body, str) else "")
return True


def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)

validate = subparsers.add_parser("validate-pr")
validate.add_argument("--base-ref", required=True)
validate.add_argument("--event-path", type=Path, required=True)

render = subparsers.add_parser("render")
render.add_argument("--pulls-json", type=Path, required=True)
render.add_argument("--sha", required=True)
render.add_argument("--output", type=Path, required=True)
return parser


def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
try:
if args.command == "validate-pr":
required = validate_pull_request_event(args.event_path, args.base_ref)
if required:
print("Release notes are valid for the new component version.")
else:
print("Release notes are not required because the component version is unchanged.")
return 0

pulls = json.loads(args.pulls_json.read_text())
args.output.write_text(render_release_notes(pulls, args.sha))
print(f"Release notes written to {args.output}.")
return 0
except (ReleaseNotesError, json.JSONDecodeError, OSError, subprocess.CalledProcessError) as err:
print(f"ERROR: {err}", file=sys.stderr)
return 1


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions tests/haos_image_build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ lane, trimmed to this component:
- boot a pinned HAOS qcow2,
- onboard Home Assistant,
- install and start the official ESPHome Device Builder add-on,
- bootstrap the complete HACS release through the supported Get HACS add-on,
- shut down HAOS,
- inject `custom_components/esphome_mcp` and an enabled config entry into
`/supervisor/homeassistant`.
Expand Down
Loading
Loading