Skip to content

Release

Release #316

Workflow file for this run

name: Release
# Cuts a release. Validates the requested version, builds the
# Python wheel + sdist (containing the prebuilt frontend), drafts
# a GitHub release with assets attached, publishes the wheel to
# PyPI, then flips the GitHub release to public (which creates
# the tag at the recorded commitish), and finally opens / updates
# a single bump PR on the backend repo so it picks up the new
# PyPI version.
on:
workflow_dispatch:
inputs:
version:
description: "Version number (e.g. 0.2.0)"
required: true
type: string
env:
PYTHON_VERSION: "3.14"
NODE_VERSION: "24.x"
BACKEND_REPO: esphome/device-builder
PACKAGE_NAME: esphome-device-builder-frontend
permissions: {}
# Only one release run at a time, ever. A second run queues
# behind the first rather than cancelling it — cancelling
# mid-publish could leave PyPI and the GitHub release in
# inconsistent states.
concurrency:
group: release
cancel-in-progress: false
jobs:
validate:
name: Validate version and tag
runs-on: ubuntu-latest
permissions:
contents: read # gh api reads git refs on this repo
steps:
- name: Validate version format
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Version '$VERSION' must be X.Y.Z."
exit 1
fi
echo "Version $VERSION is valid."
- name: Verify tag does not already exist
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# `gh api` exits 0 on 2xx and non-zero on any other outcome
# (HTTP error or transport failure), so a bare exit-code check
# can't tell "tag doesn't exist" apart from "the network ate
# the request". Capture stderr and require an HTTP 404 before
# treating the tag as absent — anything else fails loudly.
if err=$(gh api "repos/${{ github.repository }}/git/refs/tags/${{ inputs.version }}" --silent 2>&1); then
echo "::error::Tag '${{ inputs.version }}' already exists. Pick a new version."
exit 1
elif ! grep -q "HTTP 404" <<<"$err"; then
echo "::error::Failed to check whether tag '${{ inputs.version }}' exists."
echo "gh api stderr:"
echo "$err"
exit 1
fi
translations:
name: Download translations from Lokalise
needs: validate
runs-on: ubuntu-latest
# The Lokalise credentials live in the `lokalise` environment, so the
# API token is only ever exposed to this small job — not to the build
# job, which runs `pnpm install --frozen-lockfile` and the full
# bundler. If the environment isn't configured the download is skipped
# and the wheel ships English-only (see the secrets guard below).
environment: lokalise
permissions:
contents: read # actions/checkout uses the implicit GITHUB_TOKEN
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download from Lokalise
# Non-English locales are gitignored, so pull them before bundling to
# ship a fully-translated wheel. On the canonical repo a real release
# MUST ship translations, so unset secrets hard-fail rather than
# silently shipping an English-only wheel (a credential regression
# would otherwise degrade every release behind an easily-missed
# ::warning::). On a fork the secrets are unreachable, so building
# English-only is the expected, non-fatal outcome.
env:
LOKALISE_API_TOKEN: ${{ secrets.LOKALISE_API_TOKEN }}
LOKALISE_PROJECT_ID: ${{ secrets.LOKALISE_PROJECT_ID }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ -z "${LOKALISE_API_TOKEN}" ] || [ -z "${LOKALISE_PROJECT_ID}" ]; then
if [ "${REPO}" = "esphome/device-builder-frontend" ]; then
echo "::error::Lokalise secrets unset on the canonical repo; a release must ship translations."
exit 1
fi
echo "::warning::Lokalise secrets unset; building English-only (fork)."
exit 0
fi
pnpm run translations:download
- name: Upload translations artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: translations
# en.json is always present, so the artifact stays non-empty even
# when no locales were downloaded — the build job's restore step
# then never fails on a missing artifact.
path: src/translations/
build:
name: Build wheel and sdist
needs: [validate, translations]
runs-on: ubuntu-latest
permissions:
contents: read # actions/checkout uses the implicit GITHUB_TOKEN
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Tag doesn't exist yet — it's created when the draft release
# is published. Build from the SHA the workflow ran on, which
# is also what release-drafter targets via commitish.
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Set up pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
python3 -m pip install build tomli tomli-w
- name: Stamp version into pyproject.toml and package.json
shell: python
run: |
import json
import tomli
import tomli_w
version = "${{ inputs.version }}"
with open("pyproject.toml", "rb") as f:
pyproject = tomli.load(f)
pyproject["project"]["version"] = version
with open("pyproject.toml", "wb") as f:
tomli_w.dump(pyproject, f)
with open("package.json") as f:
pkg = json.load(f)
pkg["version"] = version
with open("package.json", "w") as f:
json.dump(pkg, f, indent=2)
f.write("\n")
- name: Restore translations
# Locale files come from the `translations` job (which holds the
# Lokalise credentials); the build job never sees the API token.
# The artifact always carries en.json, so this overwrites the
# checked-out copy with itself and adds any downloaded locales.
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
path: src/translations/
- name: Build frontend bundle
run: pnpm run build
- name: Build wheel and sdist
run: |
rm -rf build *.egg-info
python3 -m build --wheel --sdist
- name: Upload dist artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
draft-release:
name: Draft GitHub release and attach assets
needs: build
runs-on: ubuntu-latest
permissions:
contents: read # actions/checkout uses the implicit GITHUB_TOKEN; release writes go via the app token
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.sha }}
persist-credentials: false
- name: Generate release notes (draft)
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
with:
config-name: release-drafter.yml
version: ${{ inputs.version }}
tag: ${{ inputs.version }}
commitish: ${{ github.sha }}
publish: false
prerelease: false
env:
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
- name: Download dist artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- name: Download translations artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
path: translations/
- name: Package translations
# Bundle the locale files the build shipped into a single release
# asset. `translations:download --source release` reads this zip
# to reproduce a release's locales without a Lokalise token.
run: |
set -euo pipefail
cd translations
zip -j ../translations.zip *.json
- name: Clear existing assets on draft release
# On a partial-failure rerun the draft release may already
# carry assets from the previous attempt. Strip them so the
# upload starts from a clean slate.
run: |
gh release view "${{ inputs.version }}" --json assets --jq '.assets[].name' \
| while IFS= read -r asset; do
[ -z "$asset" ] && continue
gh release delete-asset "${{ inputs.version }}" "$asset" --yes
done
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
- name: Attach artifacts to draft release
run: gh release upload "${{ inputs.version }}" dist/*.whl dist/*.tar.gz translations.zip
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
publish-pypi:
name: Publish to PyPI
needs: draft-release
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/esphome-device-builder-frontend/${{ inputs.version }}/
permissions:
id-token: write # OIDC for PyPI Trusted Publishing
steps:
- name: Download dist artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
# Skip dists already on PyPI so a partial-failure rerun
# (e.g. release-edit failed after PyPI succeeded) doesn't
# blow up on the duplicate version.
skip-existing: true
publish-release:
name: Publish GitHub release
needs: publish-pypi
runs-on: ubuntu-latest
environment:
name: gh-release
url: https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }}
permissions: {} # all GitHub writes go via the app token
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
- name: Publish release
# Flipping draft → public causes GitHub to create the tag
# at the recorded ``commitish`` SHA, attributed to the app
# token (esphome[bot]).
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
run: gh release edit "${{ inputs.version }}" --draft=false --repo "${{ github.repository }}"
bump-backend:
name: Open bump PR on backend
needs: publish-release
runs-on: ubuntu-latest
environment:
name: backend
url: ${{ steps.bump-pr.outputs.pull-request-url }}
permissions:
contents: read # GITHUB_TOKEN reads this repo's release body
steps:
- name: Wait for PyPI to index the new version
# PyPI's index is eventually consistent — ``publish-pypi``
# returns success when the artifact lands on storage, but
# the simple index that the backend's ``pip`` / ``uv``
# resolves through lags behind. Without a gate the backend
# bump PR's CI fires before the version is visible and
# fails with ``ResolutionImpossible``, requiring a manual
# re-run ~2 min later. Empirically a flat 90s sleep is
# enough for the index to propagate to the runner pool the
# backend's CI lands on.
run: sleep 90
- name: Create backend app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: |
device-builder
permission-contents: write
permission-pull-requests: write
permission-issues: write
- name: Checkout backend
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ env.BACKEND_REPO }}
token: ${{ steps.app-token.outputs.token }}
path: backend
persist-credentials: false
- name: Get release notes
id: notes
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
python3 - <<'PY'
import os
import re
import subprocess
repo = os.environ["GITHUB_REPOSITORY"]
notes = subprocess.check_output(
["gh", "release", "view", os.environ["VERSION"],
"--repo", repo,
"--json", "body", "--jq", ".body"],
text=True,
)
# Qualify bare #NNN refs with the source repo so they resolve
# correctly when this body is rendered on the backend repo's PR.
notes = re.sub(r"(?<![\w/])#(\d+)\b", rf"{repo}#\1", notes)
# Defuse @user mentions so the backend PR doesn't ping contributors.
# The trailing lookahead skips scoped npm package refs like
# @home-assistant/webawesome that appear in PR titles.
notes = re.sub(
r"(?<![\w@`])@([A-Za-z0-9][A-Za-z0-9-]*)(?![A-Za-z0-9/-])",
r"`@\1`",
notes,
)
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write("body<<NOTES_EOF\n")
f.write(notes)
if not notes.endswith("\n"):
f.write("\n")
f.write("NOTES_EOF\n")
PY
- name: Update backend pyproject.toml
working-directory: backend
env:
VERSION: ${{ inputs.version }}
run: |
python3 - <<'PY'
import os
import re
from pathlib import Path
version = os.environ["VERSION"]
package = os.environ["PACKAGE_NAME"]
dep = f'"{package}=={version}"'
path = Path("pyproject.toml")
text = path.read_text()
# Replace any existing pin (URL form or bare ==), or insert
# at the top of the dependencies array if not yet present.
pattern = re.compile(
rf'^\s*"{re.escape(package)}\s*[@=].*?",?\s*$',
re.MULTILINE,
)
text, replaced = pattern.subn(f" {dep},", text)
if replaced == 0:
text = re.sub(
r"(dependencies\s*=\s*\[)",
rf"\1\n {dep},",
text,
count=1,
)
path.write_text(text)
PY
- name: Open / update PR
id: bump-pr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
path: backend
token: ${{ steps.app-token.outputs.token }}
# Stable branch — every release pushes onto the same
# branch, so peter-evans updates the existing PR rather
# than leaving stale ones around.
branch: auto-bump-frontend
delete-branch: true
commit-message: "Bump frontend to ${{ inputs.version }}"
title: "Bump frontend to ${{ inputs.version }}"
body: |
Bumps `${{ env.PACKAGE_NAME }}` to [`${{ inputs.version }}`](https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }}).
${{ steps.notes.outputs.body }}
## Types of changes
<!--
The backend's ``pr-labels`` workflow parses this checklist
to apply the release-notes label. Auto-bump PRs are always
a ``dependencies`` bump by definition; the box is ticked
here so the check passes without manual editing.
-->
- [ ] Bugfix (non-breaking change which fixes an issue) — `bugfix`
- [ ] New feature (non-breaking change which adds functionality) — `new-feature`
- [ ] Enhancement to an existing feature — `enhancement`
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — `breaking-change`
- [ ] Refactor (no behaviour change) — `refactor`
- [ ] Documentation only — `docs`
- [ ] Maintenance / chore — `maintenance`
- [ ] CI / workflow change — `ci`
- [x] Dependencies bump — `dependencies`
labels: |
dependencies
frontend