Release #307
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: 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 |