From 3bc1cda866ae3d7c06f5923ce1b2f9dacb19a305 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 20 Jun 2026 13:30:34 -0700 Subject: [PATCH 1/8] Adopt ProjectTemplate Docker release pipeline and conventions Bring ESPHome-NonRoot in-line with ptr727/ProjectTemplate (develop) as a Docker-only derived repo: carry the cross-cutting contract verbatim and re-sync it going forward, while preserving the three ESPHome-specific behaviors the template has no equivalent for. Pipeline: replace the monolithic BuildDockerPush.yml with the template's two-layer model. Orchestration is carried verbatim (get-version-task, build-datebadge-task, publish-release, and the get-version + github-release jobs in build-release-task); the build layer is the owned build-docker-task leaf. Drop the nuget/pypi/executable targets and the publish-pypi job, and the .NET unit-test job from test-pull-request, keeping the ruleset-bound "Check pull request workflow status" aggregator. Two-phase model (PUBLISH_ON_MERGE unset): PRs smoke-test amd64 only; the weekly schedule and manual dispatch publish both branches. ESPHome-specific behavior, aligned with sibling downstreams: - build-docker-task tags :latest/:develop plus the pinned upstream ESPHome version and passes it as the ESPHOME_VERSION build-arg, read from the committed .github/esphome-version.json state file. - check-esphome-version replaces CheckUpstreamRelease, mirroring homeassistant-purpleair's check-ha-version: it resolves the latest ESPHome from PyPI and opens a rolling, App-authored, signed bump PR (dual-targeted across main and develop since the version is shipped content) that merge-bot auto-merges and the next publish ships. - publish-docker-readme-task mirrors NxWitness: it pushes a static Docker/README.md via DOCKER_HUB_ACCESS_TOKEN, replacing the m4/toolversions machinery and the DOCKER_HUB_PASSWORD secret. Carry verbatim: AGENTS.md (Docker-only adaptation), copilot-instructions.md, .markdownlint-cli2.jsonc, .editorconfig (C# block trimmed, deviation noted), .gitattributes. Add a dual-target docker Dependabot ecosystem and point the README build badge at publish-release.yml. --- .editorconfig | 55 ++++ .gitattributes | 10 +- .github/copilot-instructions.md | 168 ++++++++++++ .github/dependabot.yml | 32 +++ .github/esphome-version.json | 4 + .github/workflows/BuildDockerPush.yml | 252 ------------------ .github/workflows/CheckUpstreamRelease.yml | 73 ----- .github/workflows/build-datebadge-task.yml | 35 +++ .github/workflows/build-docker-task.yml | 131 +++++++++ .github/workflows/build-release-task.yml | 165 ++++++++++++ .github/workflows/check-esphome-version.yml | 110 ++++++++ .github/workflows/get-version-task.yml | 75 ++++++ .github/workflows/merge-bot-pull-request.yml | 208 +++++++++++++++ .../workflows/publish-docker-readme-task.yml | 39 +++ .github/workflows/publish-release.yml | 126 +++++++++ .github/workflows/test-pull-request.yml | 104 ++++++++ .markdownlint-cli2.jsonc | 15 ++ AGENTS.md | 224 ++++++++++++++++ Docker/README.m4 | 37 --- Docker/README.md | 28 ++ README.md | 4 +- 21 files changed, 1526 insertions(+), 369 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/copilot-instructions.md create mode 100644 .github/esphome-version.json delete mode 100644 .github/workflows/BuildDockerPush.yml delete mode 100644 .github/workflows/CheckUpstreamRelease.yml create mode 100644 .github/workflows/build-datebadge-task.yml create mode 100644 .github/workflows/build-docker-task.yml create mode 100644 .github/workflows/build-release-task.yml create mode 100644 .github/workflows/check-esphome-version.yml create mode 100644 .github/workflows/get-version-task.yml create mode 100644 .github/workflows/merge-bot-pull-request.yml create mode 100644 .github/workflows/publish-docker-readme-task.yml create mode 100644 .github/workflows/publish-release.yml create mode 100644 .github/workflows/test-pull-request.yml create mode 100644 .markdownlint-cli2.jsonc create mode 100644 AGENTS.md delete mode 100644 Docker/README.m4 create mode 100644 Docker/README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..acada95 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,55 @@ +# https://editorconfig.org + +# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/identifier-names +# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/overview + +# https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md +# https://github.com/dotnet/runtime/blob/main/.editorconfig + +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format +# dotnet format style --verify-no-changes --severity=info --verbosity=detailed + +# Root config +root = true + +# Defaults +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +# Markdown files +[*.md] +end_of_line = crlf +trim_trailing_whitespace = false + +# Xml files +[*.{xml,csproj,props,targets}] +end_of_line = crlf +indent_size = 2 + +# Yaml files +[*.{yml,yaml}] +end_of_line = crlf +indent_size = 2 + +# Json and JsonC files +[*.{json,jsonc}] +end_of_line = crlf + +# Linux scripts +[*.sh] +end_of_line = lf + +# Windows scripts +[*.{cmd,bat,ps1}] +end_of_line = crlf + +# C# / ReSharper style rules from the upstream ProjectTemplate .editorconfig +# are intentionally omitted: this is a Docker-only repository with no C# +# sources. The per-extension line-ending rules above are the mandatory +# verbatim carry (see AGENTS.md "Files and Sections Derived Repos Must Carry +# Verbatim"); re-sync them from ptr727/ProjectTemplate. diff --git a/.gitattributes b/.gitattributes index 204db57..d4823c7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,5 @@ -# Leave line endings alone -# git config --global core.autocrlf false -# git add --renormalize . -# git ls-files --eol -* -text +# Leave line endings alone +# git config --global core.autocrlf false +# git add --renormalize . +# git ls-files --eol +* -text diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..7a5c0a8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,168 @@ +# Copilot Instructions + +Repository conventions for GitHub Copilot (and any other AI agent reading this file). + +The **canonical guide is [AGENTS.md](../AGENTS.md)** at the repo root - read it first for this repo's conventions, including the [PR Review Etiquette](../AGENTS.md#pr-review-etiquette) review-loop contract that this file's runbook implements. + +This file is intentionally narrow: commit/PR-title conventions (so VS Code's AI commit-message and PR-title generators get them without an extra fetch), plus a GitHub Copilot Review Runbook that documents the provider-specific mechanics behind the review-loop contract defined in AGENTS.md. + +This repository ships only a Docker image and has no language source tree, so there is no language-specific style guide; [AGENTS.md](../AGENTS.md) is the single source of truth for all conventions. + +## Commit Messages and Pull Request Titles + +Feature -> develop PRs squash-merge - the PR title becomes the single commit on develop. Develop -> main PRs merge-commit - main's history shows one merge commit per release with develop's tip as the second parent. Titles are descriptive and have no versioning effect - versioning is handled by [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) reading [version.json](../version.json) and git history, not by parsing commit messages. + +Branch protection enforces the merge method on both bases (develop allows only squash, main allows only merge). When running `gh pr merge` against either base, pick the matching flag (`--squash` for develop, `--merge` for main); a mismatch fails with "Merge method ... is not allowed on this repository". The merge-bot workflow (`.github/workflows/merge-bot-pull-request.yml`) does this dispatch automatically for Dependabot and codegen PRs via a `case` on `base.ref` - keep that pattern when adding new auto-merge jobs. + +### Format + +- Imperative subject summarizing the change, <= 72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) +- Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. + +### Rules + +- Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine - keep them.) +- Don't add `Co-Authored-By:` lines unless the user explicitly asks. +- Don't put release-bump magnitude in the title - no "minor", "patch", "release v0.2.0", etc. NBGV computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. +- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). + +### Examples + +```text +Add structured logging extensions to library +Pin softprops/action-gh-release to commit SHA +Drop net8.0 multi-targeting from console project +Bump xunit.v3 from 3.2.2 to 3.3.0 +Clarify devcontainer setup steps in README +``` + +## GitHub Copilot Review Runbook + +> This runbook implements the [AGENTS.md "PR Review Etiquette"](../AGENTS.md#pr-review-etiquette) review-loop contract for GitHub Copilot. Without it in-repo, an agent has no pointer to the reliable Copilot mechanics and falls back to known-broken paths (the no-op `POST /requested_reviewers`, the wrong bot-login filter). In the API snippets below, fill the `ptr727` / `ESPHome-NonRoot` / `` placeholders. + +Use this section for provider-specific mechanics. The expected review loop *contract* (request review on every push, verify head-SHA coverage, triage findings, reply + resolve, escalate when stuck) is defined in [AGENTS.md -> PR Review Etiquette](../AGENTS.md#pr-review-etiquette). This section only describes how to make GitHub Copilot reliably execute it. + +### Triggering and Polling + +Auto-review on push is configured (via the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently in practice - treat it as best-effort, not guaranteed. After every push, **re-request a review programmatically** via the GraphQL `requestReviews` mutation, passing the Copilot reviewer's bot node id in `botIds`. This now works reliably (it previously did not - a maintainer had to click "re-request review" in the UI; the agent can now drive the loop end-to-end without that hand-off). + +> **The reviewer login differs by API - this is intentional, not a typo.** In **GraphQL** (`gh api graphql` and `gh pr view --json reviews`, which is GraphQL-backed) the `Bot.login` is `copilot-pull-request-reviewer` - **no `[bot]` suffix**. In the **REST** API (`gh api repos/.../issues|pulls/...`) the same account's `user.login` is `copilot-pull-request-reviewer[bot]` - **with** the suffix. Each query below uses the correct form for its API; match the API, not a single spelling, when adapting them. + +```sh +# 1. PR node id + the Copilot reviewer's bot node id (read from any existing +# Copilot review; the reviewer login is `copilot-pull-request-reviewer`). +PR_NODE=$(gh pr view --json id --jq '.id') +BOT_ID=$(gh api graphql -f query=' +{ + repository(owner: "ptr727", name: "ESPHome-NonRoot") { + pullRequest(number: ) { + reviews(first: 50) { nodes { author { __typename login ... on Bot { id } } } } + } + } +}' --jq '[.data.repository.pullRequest.reviews.nodes[] + | select(.author.login == "copilot-pull-request-reviewer") + | .author.id] | first') + +# 2. Re-request a Copilot review on the current head. +gh api graphql -f query=' +mutation($pr: ID!, $bot: ID!) { + requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { + pullRequest { id } + } +}' -F pr="$PR_NODE" -F bot="$BOT_ID" +``` + +The bot node id is read from an existing Copilot review, so step 1 needs at least one prior review on the PR - the auto-review-on-open normally supplies the first one. If no Copilot review exists yet and auto-review didn't fire, request `Copilot` once through the GitHub PR UI to seed it, then use the mutation for every subsequent re-request. + +**Do NOT post `@Copilot review` as a PR comment.** That comment triggers the Copilot *coding agent* (`copilot-swe-agent[bot]`), which makes code changes rather than posting a review. + +Known non-working request paths (don't rely on them - use the `requestReviews` mutation above instead): + +- `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. +- `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. + +### Verify Review Covered Current Head + +Before merging, confirm Copilot reviewed the current PR head SHA. Copilot may respond as either a formal review (carries an exact commit SHA) or an issue comment (no SHA - use the most recent Copilot comment for manual confirmation). Check both. + +```sh +PR_HEAD=$(gh pr view --json headRefOid --jq '.headRefOid') + +# 1. Formal review - exact SHA match. +gh pr view --json reviews --jq \ + '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .commit.oid' \ + | grep -q "$PR_HEAD" && echo "covered via formal review" + +# 2. Issue comment - show the most recent Copilot comment for manual +# confirmation. This is the REST API, so the login carries the `[bot]` suffix. +gh api repos/ptr727/ESPHome-NonRoot/issues//comments --jq \ + '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]")] | last | {created_at, body: .body[:200]}' +``` + +Coverage is confirmed when (1) exits 0. For issue comments (path 2), body content is the only reliable signal - `created_at` is not: `git log -1 --format=%cI` is the **commit** timestamp, not the push timestamp, so amended or rebased commits can have an earlier timestamp and an older Copilot comment could satisfy a time check even though Copilot never saw the current head. Treat path (2) as confirmed only when the comment body explicitly refers to the current changes. + +### Bounded Retry Workflow + +If a review did not run on the current head, retry: + +1. Wait briefly and check head-SHA coverage (see above). +1. Re-request the review via the `requestReviews` mutation (see "Triggering and Polling"); fall back to the GitHub PR UI only if the mutation no-ops. +1. Retry up to two more times (three total). +1. If still missing, mark review as blocked and escalate to the user/maintainer with what was attempted. + +### Reply and Thread Resolution Workflow + +List unresolved threads. Use `first: 100` with cursor-based pagination; if `hasNextPage` is true, re-run with `after: ""` to retrieve the next page: + +```sh +gh api graphql -f query=' +{ + repository(owner: "ptr727", name: "ESPHome-NonRoot") { + pullRequest(number: ) { + reviewThreads(first: 100) { + nodes { + id isResolved path + comments(first: 1) { nodes { author { login } body } } + } + pageInfo { hasNextPage endCursor } + } + } + } +}' | jq ' + .data.repository.pullRequest.reviewThreads | + (.pageInfo | "hasNextPage=\(.hasNextPage) endCursor=\(.endCursor)"), + (.nodes[] | select(.isResolved == false)) +' +``` + +Reply on a thread, then resolve it: + +```sh +gh api graphql -f query=' +mutation($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { id } + } +}' -F threadId="PRRT_..." -F body="Fixed in : ." + +gh api graphql -f query=' +mutation($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } +}' -F threadId="PRRT_..." +``` + +Issue-level Copilot comments (those in `issues//comments`) have no resolution action - GitHub provides no API or UI to resolve them. Reply if the finding warrants it; no resolution step is needed or possible. + +Reply-body conventions: + +- Accepted bug/style fix: include fixing commit SHA and a one-line summary. +- Declined style comment: cite the rule (AGENTS.md) and the existing-tree precedent. +- Declined architecture proposal: one-sentence rationale. + +After the final push, sweep-resolve stale older threads for removed code paths. + +## When in Doubt + +Read [AGENTS.md](../AGENTS.md) for this repo's conventions. Don't restate its rules in commit bodies or PR descriptions - keep those focused on the change itself. + +**In a derived repo:** if you find a discrepancy that should be fixed in the template itself (this file or AGENTS.md is out of date, a rule is missing, something bit this repo and would bite the next), open an issue upstream in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) rather than only fixing it locally - see the template's [AGENTS.md "Staying in Sync and Reporting Drift Upstream"](https://github.com/ptr727/ProjectTemplate/blob/main/AGENTS.md#staying-in-sync-and-reporting-drift-upstream). diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fd3c84f..8b0d46d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,17 @@ # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates # https://containers.dev/guide/dependabot +# +# Every ecosystem appears twice - once with target-branch "main" and once with +# "develop" - so Dependabot keeps both branches current independently of the +# develop -> main release cadence (main ships the published image directly). +# The merge-bot dispatches the right merge method per base; security PRs always +# open against main. See AGENTS.md "Branching Model". version: 2 updates: +# ----- github-actions ----- + # main - package-ecosystem: "github-actions" target-branch: "main" @@ -25,3 +33,27 @@ updates: actions-deps: patterns: - "*" + +# ----- docker (base image in Docker/Dockerfile) ----- + +# main +- package-ecosystem: "docker" + target-branch: "main" + directory: "/Docker" + schedule: + interval: "daily" + groups: + docker-deps: + patterns: + - "*" + +# develop +- package-ecosystem: "docker" + target-branch: "develop" + directory: "/Docker" + schedule: + interval: "daily" + groups: + docker-deps: + patterns: + - "*" diff --git a/.github/esphome-version.json b/.github/esphome-version.json new file mode 100644 index 0000000..d613b13 --- /dev/null +++ b/.github/esphome-version.json @@ -0,0 +1,4 @@ +{ + "$comment": "Pinned upstream ESPHome version, maintained by check-esphome-version.yml and read by build-docker-task.yml (image tag + ESPHOME_VERSION build-arg). Do not edit by hand; the tracker opens a bump PR when PyPI publishes a newer version.", + "version": "2026.6.2" +} diff --git a/.github/workflows/BuildDockerPush.yml b/.github/workflows/BuildDockerPush.yml deleted file mode 100644 index 8387551..0000000 --- a/.github/workflows/BuildDockerPush.yml +++ /dev/null @@ -1,252 +0,0 @@ -name: Build and push docker images - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] - workflow_dispatch: - schedule: - # Weekly 2am Monday morning - - cron: '0 2 * * MON' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - # Get version information - version: - name: Version - runs-on: ubuntu-latest - - outputs: - # Build version number - SemVer2: ${{ steps.nbgv.outputs.SemVer2 }} - # ESPHome version number - ESPHomeVersion: ${{ steps.plv.outputs.version }} - # Push / release if not a pull request - # Test for strings not variables, e.g. needs.version.outputs.PushAction == 'true' - PushAction: ${{ github.event_name != 'pull_request' }} - # Tag as latest or develop - TagName: ${{ endsWith(github.ref, 'refs/heads/main') && 'latest' || 'develop' }} - # Tag list for docker build - TagList: ${{ steps.tags.outputs.tags }} - # Docker image - ImageName: docker.io/ptr727/esphome-nonroot - - steps: - - # Checkout code - # https://github.com/marketplace/actions/checkout - - name: Checkout - uses: actions/checkout@v6 - with: - # Get all history for version calculation - fetch-depth: 0 - - # Run Nerdbank.GitVersioning - # https://github.com/marketplace/actions/nerdbank-gitversioning - - name: Run Nerdbank.GitVersioning tool - id: nbgv - uses: dotnet/nbgv@master - - # curl --silent https://api.github.com/repos/esphome/esphome/releases/latest | jq -r .tag_name - # curl --silent https://pypi.org/pypi/esphome/json | jq -r '.releases | keys | .[]' | sort -V | tail -n 1 - # curl --silent https://pypi.org/pypi/esphome/json | jq -r .info.version - - name: Get ESPHome Package Latest Version - id: plv - run: | - echo "version=$(curl --silent https://pypi.org/pypi/esphome/json | jq -r .info.version)" >> $GITHUB_OUTPUT - - # Create tags as comma separated list - - name: Get tag list - id: tags - run: | - if [[ "${{ endsWith(github.ref, 'refs/heads/main') }}" == "true" ]]; then - echo "tags=docker.io/ptr727/esphome-nonroot:latest,docker.io/ptr727/esphome-nonroot:${{ steps.plv.outputs.version }}" >> $GITHUB_OUTPUT - else - echo "tags=docker.io/ptr727/esphome-nonroot:develop" >> $GITHUB_OUTPUT - fi - - # Build and push docker images - buildpush: - name: Build and push - runs-on: ubuntu-latest - needs: version - - steps: - - # Checkout code - # https://github.com/marketplace/actions/checkout - - name: Checkout - uses: actions/checkout@v6 - - # Setup QEMU for multi architecture builds - # https://github.com/marketplace/actions/docker-setup-qemu - - name: Setup QEMU - uses: docker/setup-qemu-action@v4 - with: - platforms: linux/amd64,linux/arm64 - - # Setup docker build - # https://github.com/marketplace/actions/docker-setup-buildx - - name: Setup Buildx - uses: docker/setup-buildx-action@v4 - with: - platforms: linux/amd64,linux/arm64 - - # Login to Docker Hub - # https://github.com/marketplace/actions/docker-login - - name: Login to Docker Hub - uses: docker/login-action@v4 - if: ${{ needs.version.outputs.PushAction == 'true' }} - with: - registry: docker.io - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - # Docker build and push - # https://github.com/marketplace/actions/build-and-push-docker-images - - name: Docker build and push - uses: docker/build-push-action@v7 - with: - context: ./Docker - file: ./Docker/Dockerfile - push: ${{ needs.version.outputs.PushAction == 'true' }} - tags: ${{ needs.version.outputs.TagList }} - platforms: linux/amd64,linux/arm64 - build-args: | - LABEL_VERSION=${{ needs.version.outputs.SemVer2 }} - ESPHOME_VERSION=${{ needs.version.outputs.ESPHomeVersion }} - cache-from: type=gha - cache-to: type=gha,mode=min - - # Get tool versions from image - toolversions: - name: Tool versions - runs-on: ubuntu-latest - needs: [buildpush, version] - if: ${{ needs.version.outputs.PushAction == 'true' }} - - steps: - - # Get image size - - name: Get image size - run: | - docker pull ${{ needs.version.outputs.ImageName }}:${{ needs.version.outputs.TagName }} - touch ${{ runner.temp }}/version.info - echo Image: ${{ needs.version.outputs.ImageName }}:${{ needs.version.outputs.TagName }} >> ${{ runner.temp }}/version.info - echo Size: $(docker image inspect --format json ${{ needs.version.outputs.ImageName }}:${{ needs.version.outputs.TagName }} | jq '.[] | select(.Architecture=="amd64") | [.Size] | add' | numfmt --to=iec) >> ${{ runner.temp }}/version.info - - # Get tool versions in container - # https://github.com/marketplace/actions/docker-run-action - - name: Write tool versions to file - uses: addnab/docker-run-action@v3 - with: - image: "${{ needs.version.outputs.ImageName }}:${{ needs.version.outputs.TagName }}" - options: --volume ${{ runner.temp }}/version.info:/version.info - run: | - echo OS: $(. /etc/os-release; echo $PRETTY_NAME) >> /version.info - echo Python: $(python --version) >> /version.info - echo ESPHome: $(esphome version) >> /version.info - echo PlatformIO: $(pio --version) >> /version.info - - # Print version file contents - - name: Print versions - run: cat ${{ runner.temp }}/version.info - - # https://github.com/marketplace/actions/upload-a-build-artifact - - name: Upload version artifacts - uses: actions/upload-artifact@v7 - with: - name: versions - path: ${{ runner.temp }}/version.info - - # Update Docker README.md - updatereadme: - name: Create Docker README.md - runs-on: ubuntu-latest - needs: [toolversions, version] - if: ${{ needs.version.outputs.PushAction == 'true' }} - - steps: - - # https://github.com/marketplace/actions/checkout - - name: Checkout - uses: actions/checkout@v6 - - # https://github.com/marketplace/actions/download-a-build-artifact - - name: Download version artifacts - uses: actions/download-artifact@v8 - with: - name: versions - path: ${{ runner.temp }}/versions - - - name: Create README.md from README.m4 - run: m4 --include=${{ runner.temp }}/versions ./Docker/README.m4 > ${{ runner.temp }}/README.md - - # https://github.com/marketplace/actions/docker-hub-description - - name: Update Docker Hub README.md - uses: peter-evans/dockerhub-description@v5 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_PASSWORD }} - repository: ptr727/esphome-nonroot - short-description: ${{ github.event.repository.description }} - readme-filepath: ${{ runner.temp }}/README.md - - # Release - release: - name: Release - runs-on: ubuntu-latest - needs: [ buildpush, version ] - if: ${{ needs.version.outputs.PushAction == 'true' }} - - permissions: - contents: write - - steps: - - # https://github.com/marketplace/actions/checkout - - name: Checkout Code - uses: actions/checkout@v6 - - # Create GitHub release - # https://github.com/marketplace/actions/gh-release - - name: Create GitHub release - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true - tag_name: ${{ needs.version.outputs.SemVer2 }} - prerelease: ${{ !endsWith(github.ref, 'refs/heads/main') }} - - # Create a custom badge to report the build date - datebadge: - name: Date badge - runs-on: ubuntu-latest - needs: [release, version] - if: ${{ needs.version.outputs.PushAction == 'true' }} - - permissions: - contents: write - - steps: - - # Get date from environment as a variable - - id: date - run: | - echo "date=$(date)" >> $GITHUB_OUTPUT - - # Create badge - # https://github.com/marketplace/actions/bring-your-own-badge - - name: Build date badge - uses: RubbaBoy/BYOB@v1 - with: - name: lastbuild - label: "Last Build" - icon: "github" - status: ${{ steps.date.outputs.date }} - color: "blue" - github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/CheckUpstreamRelease.yml b/.github/workflows/CheckUpstreamRelease.yml deleted file mode 100644 index 2fbe629..0000000 --- a/.github/workflows/CheckUpstreamRelease.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Check for new upstream release - -on: - workflow_dispatch: - schedule: - # Daily 2am - - cron: '0 2 * * *' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - - # Get version information - version: - name: Version - runs-on: ubuntu-latest - - outputs: - # ESPHome PyPi version number - PyPiVersion: ${{ steps.plv.outputs.version }} - # ESPHome Docker version number - DockerVersion: ${{ steps.dlv.outputs.version }} - - steps: - - # curl --silent https://api.github.com/repos/esphome/esphome/releases/latest | jq -r .tag_name - # curl --silent https://pypi.org/pypi/esphome/json | jq -r '.releases | keys | .[]' | sort -V | tail -n 1 - # curl --silent https://pypi.org/pypi/esphome/json | jq -r .info.version - # TODO: There is a slim chance that the github release is out of sync with pypi release - - name: Get ESPHome Package Latest Version - id: plv - run: | - VERSION=$(curl --silent https://pypi.org/pypi/esphome/json | jq -r .info.version) - echo "version=$VERSION" >> $GITHUB_OUTPUT - - # curl --silent https://registry.hub.docker.com/v2/repositories/ptr727/esphome-nonroot/tags/latest | jq -r .digest - # curl --silent https://registry.hub.docker.com/v2/repositories/ptr727/esphome-nonroot/tags | jq -r '.results[] | select(.name == "latest") | .digest' - # curl --silent https://registry.hub.docker.com/v2/repositories/ptr727/esphome-nonroot/tags | jq -r '.results[] | select(.digest == "sha256:775e9fbad9f7958be65525333e0215b58976dbb7f685017e401e02e89fac39e0") | select(.name != "latest") | .name' | sort --version-sort | tail -n 1 - - name: Get ESPHome Docker Latest Version - id: dlv - run: | - DIGEST=$(curl --silent https://registry.hub.docker.com/v2/repositories/ptr727/esphome-nonroot/tags/latest | jq -r .digest) - VERSION=$(curl --silent https://registry.hub.docker.com/v2/repositories/ptr727/esphome-nonroot/tags | jq -r --arg DIGEST "$DIGEST" '.results[] | select(.digest == $DIGEST) | select(.name != "latest") | .name' | sort --version-sort | tail -n 1) - echo "version=$VERSION" >> $GITHUB_OUTPUT - - # Trigger build - buildpush: - name: Trigger build - runs-on: ubuntu-latest - needs: version - - # Trigger workflow if docker and pypi versions don't match - if: ${{ needs.version.outputs.DockerVersion != needs.version.outputs.PyPiVersion }} - - # Permissions to launch workflow - permissions: - actions: write - - steps: - - # Checkout code - # https://github.com/marketplace/actions/checkout - - name: Checkout - uses: actions/checkout@v6 - - # Run workflow - # https://cli.github.com/manual/gh_workflow_run - - name: Trigger workflow - env: - GH_TOKEN: ${{ github.token }} - run: gh workflow run BuildDockerPush.yml diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml new file mode 100644 index 0000000..7b37e73 --- /dev/null +++ b/.github/workflows/build-datebadge-task.yml @@ -0,0 +1,35 @@ +name: Build BYOB date badge task + +on: + workflow_call: + inputs: + # Logical branch this badge run is for. The badge only updates on + # `main`; the publisher passes the branch explicitly so a scheduled + # run building `develop` doesn't try to write the main badge. Required + # (no `github.ref_name` fallback) so the gate can't silently misfire. + branch: + required: true + type: string + +jobs: + + date-badge: + name: Build BYOB date badge job + runs-on: ubuntu-latest + + steps: + + - name: Get current date step + id: date + run: echo "date=$(date)" >> "$GITHUB_OUTPUT" + + - name: Build BYOB date badge step + if: ${{ inputs.branch == 'main' }} + uses: RubbaBoy/BYOB@a4919104bc0ec7cfd7f113e42c405cc45246f2a4 # v1 + with: + name: lastbuild + label: "Last Build" + icon: "github" + status: ${{ steps.date.outputs.date }} + color: "blue" + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml new file mode 100644 index 0000000..e0e8971 --- /dev/null +++ b/.github/workflows/build-docker-task.yml @@ -0,0 +1,131 @@ +name: Build Docker image task + +on: + workflow_call: + inputs: + # Input to control whether to push the Docker image to Docker Hub + push: + required: false + type: boolean + default: false + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch driving the image tags (`main` => `latest` + the pinned + # ESPHome version, anything else => `develop`). Required (no + # `github.ref_name` fallback): the publisher builds develop from a run + # whose `github.ref_name` is `main`, so a silent fallback would mistag it. + # The orchestrator always passes it explicitly. + branch: + required: true + type: string + # Smoke mode: build `linux/amd64` only (no QEMU/arm64), never push, and + # skip the shared registry `cache-to` so PR builds don't pollute the + # release buildcache. Used for fast PR feedback. + smoke: + required: false + type: boolean + default: false + +jobs: + + get-version: + name: Get version information job + uses: ./.github/workflows/get-version-task.yml + secrets: inherit + with: + ref: ${{ inputs.ref }} + + build-docker: + name: Build Docker image job + runs-on: ubuntu-latest + needs: [get-version] + + steps: + + - name: Checkout step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + + # Read the committed upstream ESPHome version from the state file the + # tracker (check-esphome-version.yml) maintains. This is a reviewed, + # version-controlled value - not a live PyPI fetch - so the build is + # deterministic and reproducible for a given commit. It drives both the + # image tag and the ESPHOME_VERSION build-arg that pins the wheel build. + - name: Get ESPHome version step + id: esphome + run: | + set -euo pipefail + version="$(jq -r .version .github/esphome-version.json)" + if [[ -z "$version" || "$version" == "null" ]]; then + echo "::error::Could not read .version from .github/esphome-version.json" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + # QEMU only exists to emulate arm64. Smoke builds are amd64-only, so + # skip it entirely to save the emulation setup cost. + - name: Setup QEMU step + if: ${{ !inputs.smoke }} + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + with: + platforms: linux/amd64,linux/arm64 + + - name: Setup Buildx step + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + with: + platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + + # Always login to Docker Hub, not just on push, to benefit from higher + # rate limits with a Docker subscription for pulls and cache reads on + # every build (including smoke). This is a CONSCIOUS choice over gating + # login on `inputs.push`: the trade-off is that fork PRs without access + # to the Docker Hub secrets cannot run the Docker smoke build. + # Acceptable here because the repo is private and PRs are same-repo; a + # public derived project that accepts fork PRs may prefer to gate this + # step on `inputs.push`. + - name: Login to Docker Hub step + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Docker build and push step + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + # The Docker build context is the `Docker/` directory (the Dockerfile + # copies only `entrypoint/`); the ESPHome version is supplied as a + # build-arg, so the state file does not need to be in the context. + context: ./Docker + push: ${{ inputs.push }} + file: ./Docker/Dockerfile + # `main` publishes the moving `:latest` tag plus the immutable pinned + # ESPHome version tag (e.g. `:2025.5.0`) so consumers can pin an exact + # upstream version. `develop` publishes only the moving `:develop` + # tag. On `develop` the second entry is an empty line that + # build-push-action ignores. + tags: | + docker.io/ptr727/esphome-nonroot:${{ inputs.branch == 'main' && 'latest' || 'develop' }} + ${{ inputs.branch == 'main' && format('docker.io/ptr727/esphome-nonroot:{0}', steps.esphome.outputs.version) || '' }} + platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + # Branch-scoped registry cache. READ both branches' caches - the + # layers are nearly identical - so a main build can seed from + # develop's cache and vice versa - but WRITE only this branch's own + # tag, and only when actually pushing. Gating the export on + # `inputs.push` (not just `!smoke`) means a non-publishing build never + # writes the shared registry cache or needs Docker Hub write creds - + # smoke builds (always push=false) are covered too. Branch-scoping is + # what lets the publisher's weekly matrix build main and develop + # concurrently in one run without the two legs overwriting a single + # shared cache (destroying hit rates). + cache-from: | + type=registry,ref=docker.io/ptr727/esphome-nonroot:buildcache-main + type=registry,ref=docker.io/ptr727/esphome-nonroot:buildcache-develop + cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/esphome-nonroot:buildcache-{0},mode=max,ignore-error=true', inputs.branch) || '' }} + build-args: | + LABEL_VERSION=${{ needs.get-version.outputs.SemVer2 }} + ESPHOME_VERSION=${{ steps.esphome.outputs.version }} + diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml new file mode 100644 index 0000000..e1887af --- /dev/null +++ b/.github/workflows/build-release-task.yml @@ -0,0 +1,165 @@ +name: Build project release task + +on: + workflow_call: + inputs: + # Input to control whether to create a GitHub release + github: + required: false + type: boolean + default: false + # Input to control whether to push the docker image to Docker Hub + dockerhub: + required: false + type: boolean + default: false + # Git ref to check out / version (empty = default checkout ref). + ref: + required: false + type: string + default: '' + # Logical branch driving config / tags / prerelease for every target. + # Required (no `github.ref_name` fallback): the publisher builds both + # `main` and `develop` from one run whose `github.ref_name` is `main`, + # so a silent fallback would mislabel the develop leg. Every caller + # passes it explicitly; a missing value should fail loudly. + branch: + required: true + type: string + # Smoke mode: reduced, never-published build for fast PR feedback. + # Forwarded to every target; also hard-disables every push below so a + # smoke run can never publish regardless of the publish flags. + smoke: + required: false + type: boolean + default: false + # Per-target presence gate. Default true (build the Docker image). A PR + # smoke run sets this from the paths-filter so the build only runs when + # the Docker context changed. (This repo ships only the Docker target; + # the template's nuget/pypi/executable gates and jobs were removed per + # AGENTS.md "Release Model" per-target subsetting.) + enable_docker: + required: false + type: boolean + default: true + +jobs: + + get-version: + name: Get version information job + uses: ./.github/workflows/get-version-task.yml + secrets: inherit + with: + ref: ${{ inputs.ref }} + + build-docker: + name: Build Docker job + if: ${{ inputs.enable_docker }} + needs: [get-version] + uses: ./.github/workflows/build-docker-task.yml + secrets: inherit + with: + # Pin to the exact commit get-version resolved (immutable), not the + # possibly-moving branch ref: the publisher passes a branch name, and a + # commit landing mid-run could otherwise build artifacts from a different + # commit than the one the release tag (also GitCommitId) points at. + ref: ${{ needs.get-version.outputs.GitCommitId }} + branch: ${{ inputs.branch }} + smoke: ${{ inputs.smoke }} + # Conditional push to Docker Hub - never on a smoke build. + push: ${{ inputs.dockerhub && !inputs.smoke }} + + github-release: + name: Publish GitHub release job + # `&& !inputs.smoke` enforces the "smoke never publishes" guarantee at the + # job level too (matching the `&& !inputs.smoke` push gates above), so a + # smoke caller that also set `github: true` still can't create a release. + if: ${{ inputs.github && !inputs.smoke }} + runs-on: ubuntu-latest + needs: [get-version, build-docker] + + steps: + + # Check out the exact built commit (NBGV `GitCommitId`), not the + # possibly-moving `inputs.ref` branch, so the uploaded release files + # match the tag even if the branch advances mid-run. + - name: Checkout code step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.get-version.outputs.GitCommitId }} + + # Collect every release asset by the `release-asset--*` artifact + # convention rather than naming individual build jobs, so this download + # step never changes when a derived project subsets targets: dropping a + # target (delete its job + this job's `needs` entry, per AGENTS.md + # "Per-target subsetting") just leaves one fewer `release-asset-*` to + # match, and a downstream ships different files by adding/replacing a + # leaf `build-*-task.yml` that uploads under this pattern - without + # editing this step. `merge-multiple` flattens the matched artifacts into + # one `./Publish` dir, exactly as the previous per-target downloads did; + # download-artifact@v7 succeeds (downloads nothing) when the pattern + # matches zero artifacts, so a file-less release (tag + LICENSE/README + # only) still works. + # NOTE: subset releases by *deleting* the target, not by `enable_*: + # false`. `enable_*` is for smoke subsetting only - it skips a build job, + # and because `github-release` `needs` those jobs, a skipped one would + # skip this release job too (GitHub Actions: a skipped `needs` job skips + # its dependents). + - name: Download release asset artifacts step + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + pattern: release-asset-${{ inputs.branch }}-* + merge-multiple: true + path: ./Publish + + # The weekly publisher re-runs even when a branch has no new commits, so + # NBGV can produce a SemVer2 that was already released. GitHub release + # creation has no built-in skip-duplicate (unlike NuGet's + # `--skip-duplicate` and PyPI's `skip-existing`), and re-publishing an + # unchanged version is exactly the churn the two-phase model avoids - so + # skip the release step when a release for this tag already exists. The + # Docker mutable tags (`latest`/`develop`) and base-image refresh still + # happen regardless, so security-only rebuilds still ship. + - name: Check for existing release step + id: release-exists + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.get-version.outputs.SemVer2 }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "Release $TAG already exists; workflow_dispatch will refresh it." + else + echo "Release $TAG already exists; skipping release creation (no-op republish)." + fi + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + # `target_commitish` MUST be set explicitly: softprops doesn't pass a + # default through, and GitHub's REST API then defaults the new tag to + # the repository's default branch (main). We pin it to NBGV's + # `GitCommitId` - the exact commit the version was computed from. This + # avoids two bugs: `github.sha` would be wrong (the publisher's branch + # matrix builds `develop` from a run whose `github.sha` is main's tip), + # and `inputs.branch` would be a moving ref (a commit landing mid-run + # could tag the release on a newer commit than the one that was built). + # The exact SHA is immutable, on the right branch, and consistent with + # both the SemVer2 tag and the uploaded artifacts. + # Skip the no-op weekly republish when the tag already exists, but always + # allow a manual `workflow_dispatch` through so it can repair/refresh a + # partially-created release for the same tag. + - name: Create GitHub release step + if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + with: + generate_release_notes: true + tag_name: ${{ needs.get-version.outputs.SemVer2 }} + target_commitish: ${{ needs.get-version.outputs.GitCommitId }} + prerelease: ${{ inputs.branch != 'main' }} + files: | + LICENSE + README.md + ./Publish/* diff --git a/.github/workflows/check-esphome-version.yml b/.github/workflows/check-esphome-version.yml new file mode 100644 index 0000000..559e528 --- /dev/null +++ b/.github/workflows/check-esphome-version.yml @@ -0,0 +1,110 @@ +name: Check ESPHome version action + +# Upstream-release tracker. Mirrors ptr727/homeassistant-purpleair's +# check-ha-version.yml: it does NOT dispatch a build. It resolves the latest +# upstream ESPHome version from PyPI, writes it into the committed state file +# .github/esphome-version.json, and opens a rolling, App-authored, signed PR +# that merge-bot auto-merges. The pinned version then ships on the next publish +# (weekly schedule / manual dispatch / PUBLISH_ON_MERGE). build-docker-task.yml +# reads the same state file for the image tag and the ESPHOME_VERSION build-arg. + +on: + schedule: + # Daily at 05:00 UTC. ESPHome publishes to PyPI on its own cadence; a daily + # check keeps the pinned version within ~24h. workflow_dispatch is available + # for urgent bumps. + - cron: '0 5 * * *' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + + check: + name: Check ESPHome PyPI version job + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Dual-target: the pinned ESPHome version is shipped image content (main + # builds the :latest and : tags), so main and develop are + # tracked independently - like Dependabot - rather than develop-only. + # See AGENTS.md "Branching Model". The head/base pairing is enforced by + # merge-bot's merge-esphome-version-bump job. + branch: [main, develop] + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + # App-authored PR so it triggers PR workflows (PRs authored by the + # default GITHUB_TOKEN do not) and merge-bot's actor guard picks it up. + # client-id per actions/create-github-app-token v3+. + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Checkout the repository step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ matrix.branch }} + token: ${{ steps.app-token.outputs.token }} + + - name: Resolve latest ESPHome version and apply step + id: resolve + run: | + set -euo pipefail + latest="$(curl --silent --fail https://pypi.org/pypi/esphome/json | jq -r .info.version)" + if [[ -z "$latest" || "$latest" == "null" ]]; then + echo "::error::Could not resolve the latest esphome version from PyPI" + exit 1 + fi + current="$(jq -r .version .github/esphome-version.json)" + echo "Branch=${{ matrix.branch }} Current=$current Latest=$latest" + if [[ "$latest" == "$current" ]]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Rewrite only the version field, preserving the $comment field. + tmp="$(mktemp)" + jq --arg v "$latest" '.version = $v' .github/esphome-version.json > "$tmp" + mv "$tmp" .github/esphome-version.json + { + echo "changed=true" + echo "latest=$latest" + echo "current=$current" + } >> "$GITHUB_OUTPUT" + + - name: Create version bump pull request step + if: ${{ steps.resolve.outputs.changed == 'true' }} + # Single rolling branch per base - at most one bump PR open per branch. + # peter-evans force-pushes the latest pending version to the branch on + # every tick, so the PR refreshes in place. sign-commits creates the + # commit via the API so it is signed (the signed-commits ruleset + # requires it). The App token makes the PR App-authored. + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + base: ${{ matrix.branch }} + branch: esphome-version-bump/${{ matrix.branch }} + delete-branch: true + sign-commits: true + title: Bump ESPHome to ${{ steps.resolve.outputs.latest }} + commit-message: Bump ESPHome to ${{ steps.resolve.outputs.latest }} + body: | + Bumps the pinned upstream ESPHome version in `.github/esphome-version.json`. + + - ESPHome: `${{ steps.resolve.outputs.current }}` -> `${{ steps.resolve.outputs.latest }}` + - Release notes: https://github.com/esphome/esphome/releases/tag/${{ steps.resolve.outputs.latest }} + - PyPI: https://pypi.org/project/esphome/${{ steps.resolve.outputs.latest }}/ + + The image tag and the `ESPHOME_VERSION` build-arg derive from this file. The new version ships on the next publish (weekly schedule, manual dispatch, or `PUBLISH_ON_MERGE`). diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml new file mode 100644 index 0000000..aa20447 --- /dev/null +++ b/.github/workflows/get-version-task.yml @@ -0,0 +1,75 @@ +name: Get version information task + +on: + workflow_call: + inputs: + # Git ref to check out and version. Empty string falls back to the + # caller's default checkout ref (`github.ref`), preserving the + # original behavior. The publisher passes an explicit branch so a + # scheduled run - which always reports `github.ref` as the default + # branch - can still compute NBGV versions for `develop` too. + ref: + required: false + type: string + default: '' + outputs: + # Version information outputs + SemVer2: + value: ${{ jobs.get-version.outputs.SemVer2 }} + AssemblyVersion: + value: ${{ jobs.get-version.outputs.AssemblyVersion }} + AssemblyFileVersion: + value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} + AssemblyInformationalVersion: + value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} + # Full SHA of the commit NBGV computed the version from. Used to pin the + # GitHub release tag to the exact built commit (immutable), rather than a + # moving branch ref. + GitCommitId: + value: ${{ jobs.get-version.outputs.GitCommitId }} + +jobs: + + get-version: + name: Get version information job + runs-on: ubuntu-latest + outputs: + SemVer2: ${{ steps.nbgv.outputs.SemVer2 }} + AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} + AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} + AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} + GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} + + steps: + + - name: Setup .NET SDK step + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: 10.x + + - name: Checkout code step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + # `dotnet/nbgv` is intentionally floated on `master` rather than + # pinned to a commit SHA - a deliberate deviation from the + # AGENTS.md "pin third-party actions to a commit SHA" rule, + # documented here so the deviation isn't accidentally "fixed" by + # a future reviewer. Justification: + # - The upstream tag stream is effectively dormant - the latest + # tag `v0.5.1` lags well behind `master` and fixes accumulate + # on `master` between tag bumps. + # - Dependabot's GitHub Actions ecosystem tracks tagged + # releases. A SHA pinned to a post-`v0.5.1` `master` commit + # would either receive no Dependabot updates (silently stale) + # or get an attempted downgrade PR to `v0.5.1`'s SHA. Neither + # beats just floating on `master`. + # - Upstream owner is Microsoft (`dotnet/`), so the + # "tag-/branch-retargeting risk" the AGENTS.md rule guards + # against is materially lower than for a random author. + # Revisit if `dotnet/nbgv` resumes regular tagged releases. + - name: Run Nerdbank.GitVersioning tool step + id: nbgv + uses: dotnet/nbgv@master diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml new file mode 100644 index 0000000..17cc7d2 --- /dev/null +++ b/.github/workflows/merge-bot-pull-request.yml @@ -0,0 +1,208 @@ +name: Merge bot pull request action + +# Three-job model: +# 1. `merge-dependabot` / `merge-esphome-version-bump` run on `opened` +# and `reopened` events only. They enable auto-merge via +# `gh pr merge --auto` once per PR. Restricting to open/reopen +# (skipping `synchronize`) is what makes step 3 below stick - if +# these jobs re-ran on every `synchronize`, they'd undo a +# maintainer-triggered disable. +# 2. The merge method (`--squash` vs `--merge`) is dispatched by a +# `case` statement on `pull_request.base.ref` so the form matches +# each branch's ruleset (develop = squash-only, main = merge-only, +# see AGENTS.md "Branching Model"). Both Dependabot and the ESPHome +# version tracker open parallel PRs against both branches; Dependabot +# security updates always target `main` and flow through the same +# code path. +# 3. `disable-auto-merge-on-maintainer-push` runs on `synchronize` +# events against bot-authored PRs when the event actor is NOT the +# same bot - i.e. a maintainer pushed commits to a bot PR. It +# calls `gh pr merge --disable-auto` so the maintainer's commits +# don't auto-merge along with the bot's content. The maintainer +# re-enables auto-merge manually (UI or `gh pr merge --auto`) +# when ready. +# +# Token strategy: +# Every job uses an App token (`actions/create-github-app-token`). +# The resulting push is committed by the App, which fires downstream +# workflows on develop and main. `GITHUB_TOKEN`-authored pushes are +# blocked from triggering further workflow runs by GitHub's recursion +# guard, which would silently skip `publish-release.yml` on the merge +# commit. The App-token path also removes the close/reopen dance +# previously used by bot PRs created under `GITHUB_TOKEN` to nudge +# the auto-merge workflow. The disable job needs an App token too: +# even though the event actor is a maintainer, the workflow context +# on a Dependabot PR runs with Dependabot's restricted secrets +# regardless of actor, so plain `GITHUB_TOKEN` would be read-only. + +on: + pull_request: + types: [opened, reopened, synchronize] + +# `cancel-in-progress: false` is load-bearing. The three-job model +# (enable on opened/reopened, disable on maintainer-triggered +# synchronize) relies on those events running to completion in arrival +# order. With cancel-in-progress: true, a fast follow-up synchronize +# (e.g. a Dependabot rebase right after PR open) would cancel the +# in-flight `opened` run before it reached `gh pr merge --auto`, and +# the new synchronize run skips the enable jobs (opened/reopened +# filter), leaving auto-merge never enabled. Queueing instead of +# cancelling makes the final state deterministic: opened enables, +# then any subsequent synchronize disables (if maintainer) or no-ops +# (if bot). Action-aware grouping has its own race (opened finishing +# after a maintainer synchronize would re-enable auto-merge), so we +# keep a single group and just disable cancellation. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + + merge-dependabot: + name: Merge dependabot pull request job + runs-on: ubuntu-latest + # Restrict to Dependabot PRs that originate from this repository, not + # a fork. Only runs on `opened` / `reopened` events so the auto-merge + # enable happens once per PR; the `disable-auto-merge-on-maintainer-push` + # job below is what disables auto-merge when a maintainer pushes to a + # Dependabot branch. Skipping `synchronize` here is what keeps that + # disable sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Get dependabot metadata step + id: metadata + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + # Skip semver-major NuGet bumps: majors can build cleanly but break + # runtime behavior, so they should land via human review. Other + # ecosystems' majors (github-actions, uv) are usually safe and merge. + - name: Merge pull request step + if: >- + (steps.metadata.outputs.package-ecosystem != 'nuget') || + (steps.metadata.outputs.update-type != 'version-update:semver-major') + run: | + set -euo pipefail + case "${{ github.event.pull_request.base.ref }}" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" + exit 1 + ;; + esac + gh pr merge --auto "$method" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + merge-esphome-version-bump: + name: Merge ESPHome version bump pull request job + runs-on: ubuntu-latest + # Restrict to ESPHome-version-bump PRs that originate from the App in + # this repository. The tracker (check-esphome-version.yml) runs in a + # matrix over `main` and `develop`, so two head refs are valid: + # `esphome-version-bump/main` (always targets `main`) and + # `esphome-version-bump/develop` (always targets `develop`). The + # head/base pairing is enforced strictly so a misconfigured workflow + # can't, for example, sneak a develop bump into `main`. + # Only runs on `opened` / `reopened` events so the auto-merge enable + # happens once per PR; the `disable-auto-merge-on-maintainer-push` + # job below is what disables auto-merge when a maintainer pushes to a + # bump branch. Skipping `synchronize` here is what keeps that disable + # sticky. + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened') && + github.event.pull_request.user.login == 'ptr727-codegen[bot]' && + github.event.pull_request.head.repo.full_name == github.repository && + ( + (github.event.pull_request.head.ref == 'esphome-version-bump/main' && github.event.pull_request.base.ref == 'main') || + (github.event.pull_request.head.ref == 'esphome-version-bump/develop' && github.event.pull_request.base.ref == 'develop') + ) + permissions: + contents: write + pull-requests: write + + steps: + + - name: Generate GitHub App token step + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Merge pull request step + run: | + set -euo pipefail + case "${{ github.event.pull_request.base.ref }}" in + develop) method=--squash ;; + main) method=--merge ;; + *) + echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" + exit 1 + ;; + esac + gh pr merge --auto "$method" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + + disable-auto-merge-on-maintainer-push: + name: Disable auto-merge on maintainer push job + runs-on: ubuntu-latest + # Fires on `synchronize` events against bot-authored PRs (Dependabot + # or the ESPHome version tracker) when the event actor is NOT the same bot - i.e. a + # maintainer pushed commits to the bot's branch. Disables auto-merge + # so the maintainer's commits don't auto-merge along with the bot's + # content. The maintainer re-enables auto-merge manually when ready + # (UI button, or `gh pr merge --auto `). + # + # `gh pr merge --disable-auto` is idempotent - calling it on a PR + # that already has auto-merge disabled is a no-op. + if: >- + github.event.action == 'synchronize' && + github.event.pull_request.head.repo.full_name == github.repository && + ( + github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'ptr727-codegen[bot]' + ) && + github.actor != github.event.pull_request.user.login + permissions: + pull-requests: write + + steps: + + - name: Generate GitHub App token step + # App token rather than GITHUB_TOKEN: on a Dependabot PR the + # workflow context runs with Dependabot's restricted secrets + # regardless of who triggered the event (GitHub gates by PR + # origin, not by event actor), and the restricted GITHUB_TOKEN + # is read-only. Same App token pattern as the other merge jobs. + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + + - name: Disable auto-merge step + run: gh pr merge --disable-auto "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/publish-docker-readme-task.yml b/.github/workflows/publish-docker-readme-task.yml new file mode 100644 index 0000000..7a8fbd8 --- /dev/null +++ b/.github/workflows/publish-docker-readme-task.yml @@ -0,0 +1,39 @@ +name: Publish docker hub readme task + +# Pushes the static Docker Hub repository overview from Docker/README.md. +# Mirrors ptr727/NxWitness's publish-docker-readme-task.yml. Called from +# publish-release.yml after a real publish (never on smoke); the README tracks +# main so it matches the :latest image. + +on: + workflow_call: + inputs: + # Branch whose Docker/README.md is published. The publisher passes main so + # the readme matches the main release image even when dispatched from + # another ref. Empty falls back to the caller's default checkout ref. + ref: + required: false + type: string + default: '' + +jobs: + + publish-readme: + name: Publish docker hub readme job + runs-on: ubuntu-latest + + steps: + + - name: Checkout step + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Update docker hub description step + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + repository: ptr727/esphome-nonroot + short-description: ${{ github.event.repository.description }} + readme-filepath: ./Docker/README.md diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..4d963af --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,126 @@ +name: Publish project release action + +on: + push: + branches: [ main, develop ] + workflow_dispatch: + schedule: + # Weekly full build/publish of both branches on Mondays at 02:00 UTC. + # This is the guaranteed publisher in the default two-phase model: routine + # merges only smoke-test, and this scheduled run republishes everything + # (also refreshing the Docker base image, e.g. `ubuntu:rolling`). + - cron: '0 2 * * MON' + +# Real publishes (schedule, dispatch, or push when PUBLISH_ON_MERGE is set) +# share a single GLOBAL, ref-independent group so they serialize: they push +# both branches' shared Docker tags + caches and create GitHub releases for +# both regardless of the triggering ref, so a ref-scoped group would let a +# scheduled run (ref=main) and a manual dispatch (ref=develop) run concurrently +# and double-push. Non-publishing `push` runs (the two-phase default) get a +# unique per-run group so they don't queue behind - or delay - a real publish; +# they only execute the no-op `setup` job and skip everything else. +concurrency: + group: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || vars.PUBLISH_ON_MERGE == 'true') && github.workflow || format('{0}-noop-{1}', github.workflow, github.run_id) }} + # Documented exception to the standard `cancel-in-progress: true` (see + # AGENTS.md "Workflow YAML Conventions"): cancelling a publish mid-flight can + # leave a partially pushed multi-arch tag set or a half-created GitHub + # release. Queue instead of cancel so each publish runs to completion. + cancel-in-progress: false + +jobs: + + # Decide WHICH branches to publish and WHETHER to publish at all: + # - push -> publish only the pushed branch, and only when the + # `PUBLISH_ON_MERGE` repository variable is `true` + # (opt-in legacy continuous-release). Unset/false => the + # default two-phase model: merges don't publish. + # - schedule -> always publish BOTH branches (the weekly full build). + # - dispatch -> always publish BOTH branches (manual on-demand publish). + setup: + name: Resolve publish plan job + runs-on: ubuntu-latest + outputs: + branches: ${{ steps.plan.outputs.branches }} + publish: ${{ steps.plan.outputs.publish }} + steps: + - name: Compute publish plan step + id: plan + env: + # Repository variable (Settings -> Actions -> Variables). Unset reads + # as empty string, so the default is the two-phase model. + PUBLISH_ON_MERGE: ${{ vars.PUBLISH_ON_MERGE }} + run: | + set -euo pipefail + case "${{ github.event_name }}" in + push) + branches='["${{ github.ref_name }}"]' + if [[ "${PUBLISH_ON_MERGE:-}" == "true" ]]; then + publish=true + else + publish=false + fi + ;; + *) + # schedule / workflow_dispatch + branches='["main","develop"]' + publish=true + ;; + esac + echo "Event=${{ github.event_name }} branches=$branches publish=$publish" + echo "branches=$branches" >> "$GITHUB_OUTPUT" + echo "publish=$publish" >> "$GITHUB_OUTPUT" + + # Full build + publish of every target for each planned branch. The branch + # matrix lets a single scheduled run publish both `main` (Release/`latest`, + # non-prerelease) and `develop` (Debug/`develop`, prerelease) - each leg + # checks out and versions its own branch via the threaded `ref`/`branch`. + publish: + name: Publish project release job + needs: [setup] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + fail-fast: false + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + permissions: + contents: write + with: + ref: ${{ matrix.branch }} + branch: ${{ matrix.branch }} + smoke: false + # Push to GitHub (release) and Docker Hub (image). This repo ships only + # the Docker target; the template's nuget/pypi flags and jobs were + # removed per AGENTS.md "Release Model" per-target subsetting. + github: true + dockerhub: true + + # Push the static Docker Hub repository overview after a real publish. + # Mirrors ptr727/NxWitness's `publish-docker-readme-task.yml` and is wired + # like `date-badge` below; the README tracks `main` so it matches the + # `:latest` image. Runs only on real publishes (gated on `setup.publish`), + # never on smoke. + docker-readme: + name: Publish docker hub readme job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + uses: ./.github/workflows/publish-docker-readme-task.yml + secrets: inherit + with: + ref: main + + date-badge: + name: Create BYOB date badge job + needs: [setup, publish] + if: ${{ needs.setup.outputs.publish == 'true' }} + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} + uses: ./.github/workflows/build-datebadge-task.yml + secrets: inherit + permissions: + contents: write + with: + # The badge task self-gates to `main`; the develop leg is a no-op. + branch: ${{ matrix.branch }} diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml new file mode 100644 index 0000000..c7debfa --- /dev/null +++ b/.github/workflows/test-pull-request.yml @@ -0,0 +1,104 @@ +name: Test pull request action + +on: + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + # Detect whether a PR touches the Docker build so we only smoke-build when it + # changed. Build-workflow files are intentionally NOT in the filter: a path + # filter can't tell a logic change in a build workflow from an action-version + # bump. A workflow-only change is therefore not smoke-built - the reusable + # workflows are exercised instead by the next run that uses them (a later code + # PR's smoke build, or the scheduled/publish run); lint workflow edits with + # `actionlint` locally before pushing (there is no CI lint job). On + # `workflow_dispatch` (no PR base to diff against) the target is forced on so + # a manual run is a full smoke build. + changes: + name: Detect changed targets job + runs-on: ubuntu-latest + # `dorny/paths-filter` lists the PR's changed files via the GitHub API + # (this job does not check out the tree), which needs `pull-requests: read`. + # The repo's default GITHUB_TOKEN is restricted, so grant it explicitly. + permissions: + contents: read + pull-requests: read + outputs: + docker: ${{ github.event_name == 'pull_request' && steps.filter.outputs.docker || 'true' }} + steps: + - name: Filter changed paths step + id: filter + if: ${{ github.event_name == 'pull_request' }} + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + filters: | + docker: + - 'Docker/**' + # The pinned ESPHome version (build-arg + tag) lives here, and the + # NBGV release version comes from version.json - a change to either + # should rebuild the image. + - '.github/esphome-version.json' + - 'version.json' + + # Fast PR feedback: build the Docker image in smoke mode (amd64-only, no + # publishing). Validates against the PR's base branch by passing + # `branch: github.base_ref`. Skipped entirely when the Docker context did not + # change (e.g. a docs-only PR). + smoke-build: + name: Smoke build changed targets job + needs: [changes] + if: ${{ needs.changes.outputs.docker == 'true' }} + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + with: + smoke: true + # Do not publish anything from a PR. + github: false + dockerhub: false + # Check out the PR head by SHA (not head_ref): the head SHA is reachable + # in the base repo via refs/pull/N/head even for fork PRs, whereas the + # head_ref branch name does not exist in the base repo for forks and + # would fail checkout. Validate it in the base branch's configuration. + # `workflow_dispatch` has no pull_request payload, so fall back to the + # triggering ref. + ref: ${{ github.event.pull_request.head.sha || github.ref_name }} + branch: ${{ github.base_ref || github.ref_name }} + enable_docker: ${{ needs.changes.outputs.docker == 'true' }} + + # TODO: Workaround for GitHub Actions not supporting status checks on conditional jobs + # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 + # This job's name is bound to the branch ruleset as the required status check + # context - do NOT rename it (see AGENTS.md "Workflow YAML Conventions"). + check-workflow-status: + name: Check pull request workflow status + runs-on: ubuntu-latest + needs: + [ changes, smoke-build ] + if: always() + steps: + - name: Check workflow results step + run: | + set -euo pipefail + exit_on_result() { + if [[ "$2" == "failure" || "$2" == "cancelled" ]]; then + echo "Job '$1' failed or was cancelled." + exit 1 + fi + } + # The paths-filter job MUST succeed: if it failed we don't know whether + # the Docker target changed, so a Docker-changing PR could merge with + # its smoke build silently skipped. Treat anything other than success + # as a block. + if [[ "${{ needs.changes.result }}" != "success" ]]; then + echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." + exit 1 + fi + # smoke-build may be legitimately skipped (Docker context unchanged) - + # `skipped` passes, only failure/cancelled blocks. + exit_on_result "smoke-build" "${{ needs.smoke-build.result }}" diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..4afb100 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,15 @@ +{ + "config": { + // Prose paragraphs and data-heavy tables/URLs are intentionally long; + // reflowing at 80 cols hurts readability and churns diffs. + "MD013": false, + // Inline HTML is used for reference-link section dividers. + "MD033": false, + // Require fenced code blocks over the legacy 4-space-indented style. + "MD046": { "style": "fenced" }, + // MD060 (table column style) is not enforced - allow both compact + // (`|a|b|`) and padded (`| a | b |`) table pipe spacing. + "MD060": false + }, + "gitignore": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c23e5f8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,224 @@ +# Instructions for AI Coding Agents + +**ESPHome-NonRoot** builds and publishes a single multi-arch Docker image ([`ptr727/esphome-nonroot`](https://hub.docker.com/r/ptr727/esphome-nonroot)) that runs [ESPHome](https://esphome.io/) as a non-root user. It is a **Docker-only repo derived from [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate)** - it carries that template's cross-cutting contract (this file, [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), [`.editorconfig`](./.editorconfig), [`.gitattributes`](./.gitattributes)) and re-syncs it periodically. There is no .NET, Python, or other language source tree, so this file is the single source of truth for all conventions; there is no separate language style guide. + +Treat this file as authoritative; don't restate its rules elsewhere. + +## Git and Commit Rules + +- **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless the developer has explicitly authorized the agent to commit for the current ask ("commit this", "open a PR", etc.). Authorization is scope-bound - it covers the commits needed for that specific task, not a blanket commit license for the rest of the session. +- **All commits must be cryptographically signed (SSH or GPG).** Branch protection enforces this on both branches; unsigned commits are rejected on push. Signing depends on environment configuration - `git config commit.gpgsign true`, a configured `user.signingkey`, and a working signing agent (loaded `ssh-agent` for SSH, or `gpg-agent` for GPG). If signing is not configured in the environment, **do not commit** - surface the missing config to the developer and stop at `git add`. Verify before any agent-authored commit (`git config --get commit.gpgsign && ssh-add -L` or the GPG equivalent). **Signing must be live before the *first* commit, not retrofitted.** Turning on `Require signed commits` against a branch that already has unsigned commits forces a rewrite of that entire history to re-sign it - changing every commit SHA and making whoever does the rewrite the committer and signer of every commit (a rebase preserves the `author` field but not the original signatures; you cannot sign another contributor's commits for them). During new-repo setup, never create commits until signing is verified. +- **Never force push.** Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force pushing rewrites shared history and can cause data loss. +- **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. + +## Branching Model + +- `develop` is the integration branch. Feature branches -> `develop` is **squash-only**; develop is kept linear. +- `develop` -> `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main, which lets the release model attribute releases to the develop commits that produced them (relevant both for the weekly publish and the opt-in `PUBLISH_ON_MERGE` mode - see "Release Model" below). Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. +- All commits on both branches must be cryptographically signed (SSH or GPG). Squash and merge commits created via the GitHub UI are signed by GitHub's web-flow key. +- **`develop` is forward-only - no `main -> develop` back-merges.** The develop ruleset's squash-only setting physically blocks merge commits on develop. Any historical back-merge commits that predate this rule must not be repeated. +- **Both rulesets intentionally omit "Require branches to be up to date before merging".** The flag is off on `main` and on `develop`, for related but distinct reasons. + - *Main:* the check is graph-based - it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. Other technical workarounds - rebasing develop onto main, or rewriting develop's history - exist but contradict the squash-only develop ruleset and the linearity invariant. + - *Develop:* the check stalls bot auto-merge when two bot PRs against develop land within the same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on `opened`/`reopened` (see below) and never auto-updates bot branches, and Dependabot's rebase isn't real-time, so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the diff onto develop's tip on merge, `required_linear_history` still enforces linearity, textual conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow status` still gates merges - the only thing lost is pre-merge detection of *semantic-but-not-textual* conflicts, which the post-merge develop CI run catches anyway. + - See the upstream [`ProjectTemplate` README "Rules / Rulesets"](https://github.com/ptr727/ProjectTemplate/blob/develop/README.md#template---github-setup) for the configured state and the export/import procedure. +- **Configuring branch protection: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (this repo uses rulesets *only*), then create **exactly two rulesets named `develop` and `main`** by exporting the template's two rulesets and re-importing them via `gh api -X POST .../rulesets` (`gh ruleset` is read-only). The names are load-bearing - this file and the workflows reference them. Full export/import + brownfield (`Require signed commits` vs unsigned history) procedure: [upstream README "Rules / Rulesets"](https://github.com/ptr727/ProjectTemplate/blob/develop/README.md#template---github-setup). +- **Bots (Dependabot and the ESPHome-version tracker) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch), and [`.github/workflows/check-esphome-version.yml`](./.github/workflows/check-esphome-version.yml) runs as a matrix over both branches, opening rolling bump PRs on branches `esphome-version-bump/main` and `esphome-version-bump/develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. +- **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-esphome-version-bump` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content; re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. +- **Why parallel dual-target rather than develop-only with eventual flow-through:** the published image (`:latest` and the `:` tag) is built from `main`, and pull consumers fetch it directly. A develop-only model would leave `main` shipping a stale ESPHome version during long-running develop features. The upstream ESPHome version *is* the shipped content, so both branches need it fresh on their own cadence (this differs from `homeassistant-purpleair`, whose HA-version tracker targets `develop` only because there it drives CI test versions, not the shipped artifact). +- **Dual-target tracker output is deterministic, so it does not conflict.** The ESPHome-version tracker writes only the resolved upstream version string into [`.github/esphome-version.json`](./.github/esphome-version.json) - no per-invocation state (timestamps, GUIDs). Both matrix legs resolving the same PyPI version produce byte-identical content, so independent `main`/`develop` bumps never diverge into a `develop -> main` merge conflict. +- **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; this repo uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form - do not reintroduce `app-id` / `CODEGEN_APP_ID`. See the upstream [README "Template - GitHub Setup"](https://github.com/ptr727/ProjectTemplate/blob/develop/README.md#template---github-setup) for the secret-setup procedure. + +## Release Model + +This repo uses the template's **two-phase model**: PRs build fast, publishing is batched. See the upstream [README "Release Distribution Model"](https://github.com/ptr727/ProjectTemplate/blob/develop/README.md#template---release-distribution-model-two-phase-by-default) for the full rationale; the load-bearing rules: + +- **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) runs a `dorny/paths-filter` `changes` job that gates a **reduced** Docker build (`linux/amd64` only, no QEMU/arm64) when the Docker context changed, never pushing. (This repo has no unit-test job - it ships no language source.) Build-workflow files are intentionally not in the path filters - a filter can't tell a logic change from an action-version bump - so a workflow-only change isn't smoke-built; the reusable workflows are exercised by the next run that uses them (a later code PR's smoke build, or the scheduled/publish run). There is no CI workflow-lint job; lint workflow edits with `actionlint` locally before pushing. +- **Merges don't publish by default.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher: its **weekly schedule** (Mondays 02:00 UTC) and **manual `workflow_dispatch`** always do the full build/publish of **both** `main` and `develop` (a branch matrix). Its `push` trigger publishes only when the **`PUBLISH_ON_MERGE` repository variable** is `true` (opt-in continuous-release). Unset/`false` = two-phase. **Consequence for upstream tracking:** because merges don't publish, a merged ESPHome-version bump ships on the **next weekly/dispatch publish**, not within ~24h. To rebuild promptly on a new ESPHome release, either set `PUBLISH_ON_MERGE=true` or have the tracker `gh workflow run publish-release.yml` after the bump merges. +- **Required check.** The `changes` job is in the `Check pull request workflow status` aggregator's `needs` and **must succeed** (not just "not fail") - a paths-filter error must never let a target-changing PR merge with its smoke build silently skipped. Skipped smoke jobs (no matching change) pass; `failure`/`cancelled` blocks. +- **Reusable-task parameter contract.** Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), `branch` (logical branch driving config/tags/prerelease - `main` => Release/`latest`/non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. **Branch-derived config keys off `inputs.branch`, never `github.ref_name`** - the publisher's matrix builds `develop` from a run whose `github.ref_name` is `main`, so `ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one run. `get-version-task.yml` takes a `ref` so NBGV versions the right branch. +- **Orchestration vs. build - the override seam.** The pipeline splits into two layers. The **orchestration** layer is generic and synced verbatim from the template: [`publish-release.yml`](./.github/workflows/publish-release.yml) (publish plan + branch matrix), the `get-version` + `github-release` jobs inside [`build-release-task.yml`](./.github/workflows/build-release-task.yml), [`get-version-task.yml`](./.github/workflows/get-version-task.yml), [`build-datebadge-task.yml`](./.github/workflows/build-datebadge-task.yml), and the aggregator shape of [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml). The **build** layer is the leaf task this repo owns: [`build-docker-task.yml`](./.github/workflows/build-docker-task.yml). The seam contract: a target contributes files to the GitHub release by uploading an artifact named `release-asset--`, and the `github-release` job collects every `release-asset--*` by pattern and **never names a build job**, so it is reusable verbatim. This repo ships **only a Docker image, pushed directly to Docker Hub by `build-docker-task`** (an image-registry push contributes **no** `release-asset-*`), so `github-release` globs zero assets and creates a tag + generated notes + `LICENSE`/`README.md` only. The template's other leaf tasks (nuget/pypi/executable) and the `publish-pypi` job were deleted per "Per-target subsetting"; `build-release-task.yml`'s `github-release` job is untouched, but its `needs`/job list reflects only the Docker target. See the upstream [AGENTS.md "Release Model"](https://github.com/ptr727/ProjectTemplate/blob/develop/AGENTS.md#release-model) for the full multi-target seam contract. + - `get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the version/tag - heavyweight but expected even though this repo has no .NET; acceptable as-is. +- **No-op republish guarantee.** A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases - the `github-release` job's `release-exists` check skips the create step (it keys on the version string). **Docker always re-pushes** by design: it picks up upstream base-image refreshes (`python:3.13-slim`) and is how a new ESPHome version reaches Docker Hub. Boundary: `version.json` has **no `pathFilters`**, so *any* commit - including a CI/workflow-only or docs-only change - advances the NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it even when the image is otherwise unchanged. This is accepted NBGV behavior; `pathFilters` are intentionally not added. +- **Versioning is semantic and maintainer-controlled.** The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height (the SemVer patch position) for the build version. `main` (the public release ref) builds a stable `X.Y.`; `develop` builds a prerelease `X.Y.-g`. The maintainer edits `version.json`; dependency bumps, CI/workflow fixes, doc edits, and template re-syncs leave it untouched. + - **Bump `version.json` only for functional changes, by maintainer instruction.** Raise the major/minor when the work being introduced warrants a new semantic version - a new feature, a behavior or API change, a breaking change - and do it in the PR that introduces that work (typically on `develop`). Do **not** bump on a fixed cadence or mechanically after a release. NBGV advances the patch (git height) on every commit automatically, so a release always gets a fresh build version without any `version.json` edit. + - **`develop` need not lead `main`; no post-release bump.** Because `develop` builds are always prereleases (`X.Y.-g`), they are ordered below same-`X.Y` stable releases *by design* - that is what "prerelease" means - so there is no need to keep `develop` a minor ahead, and no `bump-version-X.Y` PR after a release. `develop` *may* sit at a higher major/minor than `main` whenever functional work in flight has bumped it, but that is incidental, not a requirement. A `develop -> main` promotion simply carries whatever `version.json` is current: a promotion that introduced a functional bump releases that new version on `main`; a maintenance-only promotion (dependency bumps, CI/doc fixes, template re-syncs) carries the unchanged `version.json` and `main` advances only its NBGV height. + +## Pull Request Title and Commit Message Conventions + +### Format + +- Imperative subject summarizing the change, <=72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) +- Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. + +### Rules + +- Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine - keep them.) +- Don't add `Co-Authored-By:` lines unless the developer explicitly asks. +- Don't put release-bump magnitude in the title - no "minor", "patch", "release v0.2.0", etc. Nerdbank.GitVersioning computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. +- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). + +### Examples + +```text +Add structured logging extensions to library +Pin softprops/action-gh-release to commit SHA +Drop net8.0 multi-targeting from console project +Bump xunit.v3 from 3.2.2 to 3.3.0 +Clarify devcontainer setup steps in README +``` + +## Documentation Style Conventions + +### Markdown + +- Use reference-style links for any URL referenced more than once or appearing in lists; alphabetize the reference definitions block. +- Inline single-use relative links (e.g. `[CODESTYLE.md](./CODESTYLE.md)`) are fine. +- One logical paragraph per line; no hard-wrap line-length limit. +- Headings follow the title-case-with-short-bind-words rule from the PR-title section. +- **Write docs in the current state, not as a change from a prior one.** The reader has no memory of the previous behavior, so describe what *is*: "X does Y", never "X *now* does Y", "X *no longer* does Z", "changed/switched/restored to Y", or "X *still* does W". Before/after framing belongs in changelogs, commit messages, and PR descriptions - where the prior state is the point - not in `README.md` or other living docs. + +### Character Set + +- **Write ASCII in all agent-authored text** - documentation, code, comments, commit messages, and PR descriptions. The agent does not introduce non-ASCII characters. Replace typographic Unicode with its ASCII equivalent on sight: + - em dash (U+2014) and en dash (U+2013) -> hyphen `-` (use a spaced ` - ` for an em-dash-style clause break) + - right arrow (U+2192) -> `->`; double arrow (U+21D2) -> `=>` + - less-than-or-equal (U+2264) -> `<=`; greater-than-or-equal (U+2265) -> `>=` + - curly quotes (U+2018/U+2019/U+201C/U+201D) -> straight `'` and `"`; ellipsis (U+2026) -> `...` +- **Allowed non-ASCII (two narrow exceptions):** + - **Scientific or technical symbols with no clean ASCII equivalent** - e.g. ohm, micro, degree, pi. Keep the symbol; do not approximate it away. + - **Unicode the developer deliberately typed** - emoji used for emphasis or as callout markers (for example the warning/info markers a maintainer placed in `README.md`). Preserve it; never strip the developer's own characters. This carve-out is for developer-authored text, not a license for the agent to add emoji. + +### Line Endings + +- **[`.editorconfig`](./.editorconfig) defines the correct line ending per file type:** **CRLF** for `.md`, `.cs`, XML/`.csproj`/`.props`/`.targets`, `.yml`/`.yaml`, `.json`, and `.cmd`/`.bat`/`.ps1`; **LF** for `.sh`. `.gitattributes` is `* -text`, so git stores the exact bytes you commit and will **not** normalize endings for you. +- **New files:** create them with the `.editorconfig`-mandated ending. +- **Editing an existing file:** **preserve the file's current line endings** - do not reflow them as a side effect of a content change, even if the file is already non-compliant. A tool that rewrites a file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify before staging: `git diff --stat` should touch only the lines you changed, and `file ` should report the file's expected ending. If a diff balloons to the whole file, you flipped the endings - restore them and re-stage. +- **Fixing a non-compliant file:** bring it to its `.editorconfig` ending as a **deliberate** change, and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader maintenance change has to normalize endings alongside content edits (a repo-wide cleanup sometimes does), call it out explicitly in the commit/PR description and verify the content separately with `git diff --ignore-cr-at-eol`. +- **Derived repos must carry both files.** [`.editorconfig`](./.editorconfig) **and** [`.gitattributes`](./.gitattributes) are mandatory verbatim carries (see [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim)). A derived repo missing either file, or one whose `.editorconfig` sets `end_of_line` only under `[*.md]` instead of carrying the full per-extension rules, will accumulate files mixed between LF and CRLF - the exact failure these two files prevent. + +### Quantitative Claims + +- Any quantitative claim in `README.md` (counts, sizes, version floors, supported platforms) must be verified against current code. If a doc number is derived from a code constant, mark the dependency in a source-code comment so the next editor knows to update both. + +## PR Review Etiquette + +> **Mandatory in every derived repo.** This entire "PR Review Etiquette" section is the provider-agnostic review-loop *contract* and must be carried **verbatim** into every repo derived from this template, alongside the [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook" that implements it. Without both in-repo, an agent working in the derived repo has no pointer to the reliable Copilot mechanics and falls back to ad-hoc (and known-broken) behavior. See [Files and Sections Derived Repos Must Carry Verbatim](#files-and-sections-derived-repos-must-carry-verbatim). + +The repo runs a review loop on every PR: local agent iteration plus remote automated review (GitHub Copilot is the configured reviewer). Treat this as a contract regardless of which local agent authored the changes. + +### Expected Review Loop + +1. Push changes to the PR branch. +2. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it explicitly via the `requestReviews` GraphQL mutation (now reliable end-to-end - see the runbook); the UI is only a fallback. +3. Wait for review activity on that head. +4. Triage findings. +5. Apply fixes or write a rationale for declines. +6. Reply to each thread and resolve what was addressed. +7. Re-run the loop after every fix push until no actionable findings remain. + +`mergeStateStatus: CLEAN` only checks required statuses; it does not block on bot review comments. Drive the loop to green - review confirmed on the latest head SHA and every actionable finding closed - and then **wait for the maintainer's explicit permission to merge**. The agent does not merge on its own (consistent with "default to staging"; merging is maintainer-authorized). + +For provider-specific mechanics (how to request review, query review state, post replies, resolve threads), see the **GitHub Copilot Review Runbook** in [.github/copilot-instructions.md](./.github/copilot-instructions.md). This file owns the contract; that file owns the mechanics. + +### Triaging Review Comments + +For each comment, classify before responding: + +- **Bug** - wrong behavior, missing test coverage, or a real divergence between code and docs. Fix it. Reply with the fixing commit SHA when done. +- **Style/convention** - the comment cites a rule from this file or a language-specific style guide. Two cases: + - The cited rule matches what the existing codebase already does -> fix the offending code. + - The cited rule contradicts what's in the tree, or industry norm -> **update the rule instead of the code**. The rule is wrong, not the code. Bouncing the same code across rounds is the symptom of a wrong rule. Heuristic: three rounds on the same style category means the rule needs adjusting and the user should authorize the rule change. +- **Architectural opinion** - the comment proposes a different design ("constrain this to disabled-by-default", "move it elsewhere", "add a runtime guardrail"). This is judgment, not a bug. Surface it to the user with a recommendation; don't apply unilaterally. + +### Responding and Resolution Expectations + +Reply inline with either the fixing commit SHA (for accepted issues) or a concise rationale (for declines). Resolve review threads when addressed or intentionally declined with rationale. Issue-level comments (those at `repos/.../issues//comments` rather than tied to a specific line) have no resolution action - acknowledge with a reply if needed and move on. + +After the final push on a PR, sweep older threads from earlier rounds whose code paths no longer exist; otherwise stale unresolved markers remain in the review UI. + +### Escalating to the User + +Bring the user in when: + +- **Genuine design trade-off** surfaces (fail-open vs fail-closed, narrow vs broad refactor scope, "should we add a guardrail or trust the docstring"). Triage, recommend, ask. +- **Repeated friction** across rounds without convergence - that's the rule-needs-updating signal. Stop, summarize the pattern, and let the user authorize the rule change. +- **Architectural redesign** is requested rather than a bug fix. Surface with a recommendation; never apply unilaterally. + +Anti-pattern: don't keep flipping the code on the same style point. Flip the rule once and stick to the rule. + +## Workflow YAML Conventions + +These conventions describe the target state. New and modified workflows must respect them; the rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. + +- **Action pinning**: pin **every** action - first-party (`actions/*`) and third-party - to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA - pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): [`dotnet/nbgv`](./.github/workflows/get-version-task.yml) is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade - the rationale is documented inline in that workflow. +- **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix; they end with what they do - `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. +- **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`); entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. +- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"**; every step's `name:` ends in **"step"**. **Exception**: a job whose `name:` is also referenced as a required-status-check `context:` in a branch ruleset (currently `Check pull request workflow status` in `test-pull-request.yml`) keeps the ruleset-bound name verbatim - renaming would silently break required-status-check enforcement. Do not "fix" that name; if a future job becomes ruleset-bound, mark it the same way. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order - cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push; and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. +- **Shells**: multi-line `run:` blocks with bash start with `set -euo pipefail` - fail fast, fail on undefined vars, fail on a failed pipe segment. +- **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. +- **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks - one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans; `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms - `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. Either declare permissions at the call site, or omit the inner block and inherit. +- **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies - `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. +- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly - without it, GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (wrong branch in the publisher's branch matrix - a `develop` leg runs with `github.sha` = main's tip) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). + +### Running the Linters Locally (Known-Working Invocations) + +There is no CI lint job for workflow YAML or Markdown - the gate is local, so an agent must know how to actually run these tools. Some linters are not obvious to invoke and their non-Docker install paths (curl-pipe installers, global npm) are frequently blocked in sandboxes or fail on WSL. **Prefer the Docker invocations below; they are the known-working path and need no local toolchain.** Both tools auto-discover their targets from the working directory. + +- **actionlint** (GitHub Actions workflow YAML - run after any `.github/workflows/` edit, since workflow-only changes are not smoke-built): + + ```sh + docker run --rm -v "$PWD":/repo --workdir /repo rhysd/actionlint:latest -color + ``` + + The `rhysd/actionlint` image bundles `shellcheck`, so it also validates `run:` shell blocks. The direct-binary/curl-installer path is often sandbox-blocked - use Docker. + +- **markdownlint-cli2** (Markdown - mirrors the davidanson VS Code extension via the shared [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), so the CLI and IDE agree): + + ```sh + docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2:latest "**/*.md" + ``` + + In a configured editor the davidanson extension is enough; use the Docker CLI when there's no IDE (agent/headless) or to confirm a clean run before pushing. + +When pulling a public image fails on a Docker-Desktop/WSL credential-helper error (`docker-credential-desktop.exe: exec format error`), retry with an empty Docker config: `DOCKER_CONFIG=$(mktemp -d) docker run ...` after writing `{}` to `$DOCKER_CONFIG/config.json`. + +## Devcontainer + +The repo ships a single [`.devcontainer/devcontainer.json`](./.devcontainer/devcontainer.json) that **builds and runs the project's own [`Docker/Dockerfile`](./Docker/Dockerfile)** - it is an ESPHome runtime environment (the dashboard, the `esphome` CLI, PlatformIO) for developing and testing ESPHome configs against the same image the repo ships, not a language toolchain. Open [`ESPHome-NonRoot.code-workspace`](./ESPHome-NonRoot.code-workspace) and pick **Reopen in Container**. The container's `customizations.vscode.extensions` mirrors the `recommendations` array in the workspace file - when you add an extension to one, add it to the other. + +Commit signing inside any container relies on the forwarded `ssh-agent` socket and the host SSH signing key's *public half*; the private key never enters the container. See the upstream [ProjectTemplate devcontainer docs](https://github.com/ptr727/ProjectTemplate/blob/develop/docs/devcontainer.md) for the host-setup and SSH-signing details. + +## Project Structure + +This is a Docker-only repo - there is no language source tree. + +- `Docker/` - the multi-arch container build: [`Dockerfile`](./Docker/Dockerfile) (multi-stage, `python:3.13-slim` base, builds ESPHome wheels and runs as non-root), `entrypoint/` scripts, `Compose.yml`, and the static `README.md` published to Docker Hub. +- `.github/` - workflows (the two-layer release pipeline + the ESPHome-version tracker), Dependabot, Copilot instructions, and `esphome-version.json` (the committed upstream-version state file the Docker build reads). +- `.devcontainer/` - the single ESPHome runtime devcontainer. +- `.vscode/`, `ESPHome-NonRoot.code-workspace` - editor config. +- `version.json` - NBGV version floor (drives the GitHub release tag and `LABEL_VERSION`; independent of the ESPHome image tag). + +## Template Lineage and Re-Syncing + +This repo is **derived from [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate)** (develop branch). It is past initial bootstrap; the ongoing concern is keeping the carried contract in sync (below). The orchestration workflows (`publish-release.yml`, `get-version-task.yml`, `build-datebadge-task.yml`, the `get-version` + `github-release` jobs in `build-release-task.yml`, the aggregator shape of `test-pull-request.yml`) and the mandatory shared files are synced from the template; the `build-docker-task.yml` leaf, the ESPHome-version tracker, and the Docker Hub README are owned here (the tracker mirrors `homeassistant-purpleair`'s `check-ha-version.yml`; the Docker Hub README task mirrors `NxWitness`'s `publish-docker-readme-task.yml`). + +### Files and Sections Derived Repos Must Carry Verbatim + +These artifacts are the template's cross-cutting contract. A derived repo must carry **each** of them; copy the file/section as-is and change only the noted placeholders. Re-inventing or omitting any of these is the drift the template exists to prevent. + +- **[`AGENTS.md`](./AGENTS.md) "PR Review Etiquette" section** - the provider-agnostic review-loop contract. Copy verbatim. No placeholders to change (it names no owner/repo). +- **[`.github/copilot-instructions.md`](./.github/copilot-instructions.md)** - the whole file is a drop-in; its "GitHub Copilot Review Runbook" carries the provider mechanics. Copy verbatim and change only the `` / `` / `` placeholders in the API snippets; drop language-specific style pointers that don't apply. +- **[`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc)** - the shared lint config read by both the davidanson `markdownlint` IDE extension and CLI/CI `markdownlint-cli2`, so the IDE and command line stay in lock-step. Copy verbatim (it is repo-agnostic). **On first adoption**, a repo's existing docs often carry structural debt this config surfaces (MD022/MD031/MD032 blank lines around headings/fences/lists, MD040 unlabeled fences). Clear it in one pass by running the markdownlint-cli2 Docker command from [Running the Linters Locally](#running-the-linters-locally-known-working-invocations) with `--fix` added (`docker run --rm -v "$PWD":/workdir davidanson/markdownlint-cli2:latest --fix "**/*.md"`), then hand-label any remaining unlabeled fences (MD040 - usually `text` for format/example blocks) and **re-verify the line endings of touched `.md` files** (`--fix` can rewrite a CRLF file as LF). +- **[`.editorconfig`](./.editorconfig) and [`.gitattributes`](./.gitattributes)** - line-ending governance (see [Line Endings](#line-endings)). Copy **both** verbatim. `.editorconfig` sets `end_of_line` per file type and `.gitattributes` (`* -text`) stops git from normalizing; a repo missing either, or one that only sets `end_of_line` for `[*.md]` instead of carrying the full per-extension rules, drifts between LF and CRLF. + +When the template changes one of these, re-sync the derived repo from the new version (see below). + +### Staying in Sync and Reporting Drift Upstream + +A derived repo is expected to **re-sync against the template periodically**, not just at creation: pull the current version of each verbatim-carry artifact above and re-apply it (adapting only the noted placeholders). + +**Drift flows back upstream as an issue, not a private fix.** When porting or re-syncing, if you find a discrepancy that should be fixed in the **template itself** - a gap, an outdated instruction, a missing rule, something that bit this repo and would bite the next derived repo too - **open an issue in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate)** describing it, rather than only patching it locally. A local fix realigns *this* repo; an upstream issue (then fix) corrects it *for every future derived repo* and keeps the template the single source of truth. This is exactly how the current review-loop / lint-config / brownfield-migration gaps were surfaced. + +Sync is **bidirectional**, and this repo is on the downstream end: it consumes the template's contract and reports drift up. The reverse direction (the template maintainer filing heads-up issues to each derived repo when a verbatim-carry artifact changes) is tracked in the template's own [AGENTS.md "Known Downstream Projects"](https://github.com/ptr727/ProjectTemplate/blob/develop/AGENTS.md#known-downstream-projects) table, where `ptr727/ESPHome-NonRoot` is listed as a pull (Docker Hub) consumer - the consumer model that drives this repo's two-phase choice. diff --git a/Docker/README.m4 b/Docker/README.m4 deleted file mode 100644 index 7d3319f..0000000 --- a/Docker/README.m4 +++ /dev/null @@ -1,37 +0,0 @@ -changequote(`{{', `}}') -# ESPHome-NonRoot - -[ESPHome](https://esphome.io) docker container that supports non-root operation. - -## License - -Licensed under the [MIT License](https://github.com/ptr727/ESPHome-NonRoot/LICENSE) - -![GitHub License](https://img.shields.io/github/license/ptr727/ESPHome-NonRoot) - -## Project - -Code and Pipeline is on [GitHub](https://github.com/ptr727/ESPHome-NonRoot). -Docker images are published on [Docker Hub](https://hub.docker.com/r/ptr727/esphome-nonroot). -Images are updated weekly with the latest upstream updates. - -## Docker Builds and Tags - -- `latest` : Latest `main` branch version. -- `develop` : Latest `develop` branch version. -- [version] : ESPHome version number. - -## Platform Support - -- `linux/amd64` -- `linux/arm64` - -## Tool Versions - -```text -include({{version.info}}) -``` - -## Usage - -Refer to [GitHub](https://github.com/ptr727/ESPHome-NonRoot/tree/main?tab=readme-ov-file#usage). diff --git a/Docker/README.md b/Docker/README.md new file mode 100644 index 0000000..f87db6b --- /dev/null +++ b/Docker/README.md @@ -0,0 +1,28 @@ +# ESPHome-NonRoot + +[ESPHome](https://esphome.io) Docker container that supports non-root operation. + +## License + +Licensed under the [MIT License](https://github.com/ptr727/ESPHome-NonRoot/blob/main/LICENSE). + +![GitHub License](https://img.shields.io/github/license/ptr727/ESPHome-NonRoot) + +## Project + +Code and pipeline are on [GitHub](https://github.com/ptr727/ESPHome-NonRoot). Docker images are published on [Docker Hub](https://hub.docker.com/r/ptr727/esphome-nonroot) and rebuilt on each upstream ESPHome release and weekly. + +## Docker Tags + +- `latest`: Latest `main` branch build. +- `develop`: Latest `develop` branch build. +- ``: Specific ESPHome version (e.g. `2026.6.2`). + +## Platform Support + +- `linux/amd64` +- `linux/arm64` + +## Usage + +Refer to the [GitHub README](https://github.com/ptr727/ESPHome-NonRoot/tree/main?tab=readme-ov-file#usage) for configuration, environment variables, and Compose examples. The bundled tool versions (Python, ESPHome, PlatformIO) for a given image can be inspected with `docker run --rm ptr727/esphome-nonroot:latest esphome version`. diff --git a/README.md b/README.md index fbe3974..b6ec3aa 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ I have no name!@012d4b62d376:/config$ - Build [`wheel`](https://pip.pypa.io/en/stable/cli/pip_wheel/) archives for the platform in the builder stage, and install the platform specific generated wheel packages in the final stage. - Set appropriate PlatformIO and ESPHome environment variables to store projects in `/config` and dynamic and temporary content in `/cache` volumes. - Refer to [`Dockerfile`](./Docker/Dockerfile) for container details. -- Refer to [`BuildDockerPush.yml`](./.github/workflows/BuildDockerPush.yml) and for pipeline details. +- Refer to [`publish-release.yml`](./.github/workflows/publish-release.yml) (the publisher) and [`build-docker-task.yml`](./.github/workflows/build-docker-task.yml) (the image build) for pipeline details. ## Debugging @@ -219,7 +219,7 @@ Detailed debug setup details are beyond the scope of this project, refer to my [ [commit-link]: https://github.com/ptr727/ESPHome-NonRoot/commits/main [docker-latest-version-shield]: https://img.shields.io/docker/v/ptr727/esphome-nonroot/latest?label=Docker%20Latest&logo=docker [docker-link]: https://hub.docker.com/r/ptr727/esphome-nonroot -[workflow-status-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ESPHome-NonRoot/BuildDockerPush.yml?logo=github&label=Workflow%20Status +[workflow-status-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/ESPHome-NonRoot/publish-release.yml?event=schedule&logo=github&label=Workflow%20Status [github-link]: https://github.com/ptr727/ESPHome-NonRoot [last-build-shield]: https://byob.yarr.is/ptr727/ESPHome-NonRoot/lastbuild [last-commit-shield]: https://img.shields.io/github/last-commit/ptr727/ESPHome-NonRoot?logo=github&label=Last%20Commit From 9321a023f8efdfb59fed697912f6ec87b288a45f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 20 Jun 2026 14:48:56 -0700 Subject: [PATCH 2/8] Move ESPHome version state file to repo root The pinned upstream version is a build-input version source, not GitHub platform config, so it belongs beside version.json at the repo root rather than under .github/. Update build-docker-task, check-esphome-version, test-pull-request, and AGENTS.md references. --- .github/workflows/build-docker-task.yml | 4 ++-- .github/workflows/check-esphome-version.yml | 10 +++++----- .github/workflows/test-pull-request.yml | 2 +- AGENTS.md | 5 +++-- .github/esphome-version.json => esphome-version.json | 0 5 files changed, 11 insertions(+), 10 deletions(-) rename .github/esphome-version.json => esphome-version.json (100%) diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index e0e8971..7d914d1 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -59,9 +59,9 @@ jobs: id: esphome run: | set -euo pipefail - version="$(jq -r .version .github/esphome-version.json)" + version="$(jq -r .version esphome-version.json)" if [[ -z "$version" || "$version" == "null" ]]; then - echo "::error::Could not read .version from .github/esphome-version.json" + echo "::error::Could not read .version from esphome-version.json" exit 1 fi echo "version=$version" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/check-esphome-version.yml b/.github/workflows/check-esphome-version.yml index 559e528..409811d 100644 --- a/.github/workflows/check-esphome-version.yml +++ b/.github/workflows/check-esphome-version.yml @@ -3,7 +3,7 @@ name: Check ESPHome version action # Upstream-release tracker. Mirrors ptr727/homeassistant-purpleair's # check-ha-version.yml: it does NOT dispatch a build. It resolves the latest # upstream ESPHome version from PyPI, writes it into the committed state file -# .github/esphome-version.json, and opens a rolling, App-authored, signed PR +# esphome-version.json, and opens a rolling, App-authored, signed PR # that merge-bot auto-merges. The pinned version then ships on the next publish # (weekly schedule / manual dispatch / PUBLISH_ON_MERGE). build-docker-task.yml # reads the same state file for the image tag and the ESPHOME_VERSION build-arg. @@ -68,7 +68,7 @@ jobs: echo "::error::Could not resolve the latest esphome version from PyPI" exit 1 fi - current="$(jq -r .version .github/esphome-version.json)" + current="$(jq -r .version esphome-version.json)" echo "Branch=${{ matrix.branch }} Current=$current Latest=$latest" if [[ "$latest" == "$current" ]]; then echo "changed=false" >> "$GITHUB_OUTPUT" @@ -76,8 +76,8 @@ jobs: fi # Rewrite only the version field, preserving the $comment field. tmp="$(mktemp)" - jq --arg v "$latest" '.version = $v' .github/esphome-version.json > "$tmp" - mv "$tmp" .github/esphome-version.json + jq --arg v "$latest" '.version = $v' esphome-version.json > "$tmp" + mv "$tmp" esphome-version.json { echo "changed=true" echo "latest=$latest" @@ -101,7 +101,7 @@ jobs: title: Bump ESPHome to ${{ steps.resolve.outputs.latest }} commit-message: Bump ESPHome to ${{ steps.resolve.outputs.latest }} body: | - Bumps the pinned upstream ESPHome version in `.github/esphome-version.json`. + Bumps the pinned upstream ESPHome version in `esphome-version.json`. - ESPHome: `${{ steps.resolve.outputs.current }}` -> `${{ steps.resolve.outputs.latest }}` - Release notes: https://github.com/esphome/esphome/releases/tag/${{ steps.resolve.outputs.latest }} diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index c7debfa..90f94d3 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -43,7 +43,7 @@ jobs: # The pinned ESPHome version (build-arg + tag) lives here, and the # NBGV release version comes from version.json - a change to either # should rebuild the image. - - '.github/esphome-version.json' + - 'esphome-version.json' - 'version.json' # Fast PR feedback: build the Docker image in smoke mode (amd64-only, no diff --git a/AGENTS.md b/AGENTS.md index c23e5f8..8eb9db1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Treat this file as authoritative; don't restate its rules elsewhere. - **Bots (Dependabot and the ESPHome-version tracker) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch), and [`.github/workflows/check-esphome-version.yml`](./.github/workflows/check-esphome-version.yml) runs as a matrix over both branches, opening rolling bump PRs on branches `esphome-version-bump/main` and `esphome-version-bump/develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. - **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-esphome-version-bump` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content; re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. - **Why parallel dual-target rather than develop-only with eventual flow-through:** the published image (`:latest` and the `:` tag) is built from `main`, and pull consumers fetch it directly. A develop-only model would leave `main` shipping a stale ESPHome version during long-running develop features. The upstream ESPHome version *is* the shipped content, so both branches need it fresh on their own cadence (this differs from `homeassistant-purpleair`, whose HA-version tracker targets `develop` only because there it drives CI test versions, not the shipped artifact). -- **Dual-target tracker output is deterministic, so it does not conflict.** The ESPHome-version tracker writes only the resolved upstream version string into [`.github/esphome-version.json`](./.github/esphome-version.json) - no per-invocation state (timestamps, GUIDs). Both matrix legs resolving the same PyPI version produce byte-identical content, so independent `main`/`develop` bumps never diverge into a `develop -> main` merge conflict. +- **Dual-target tracker output is deterministic, so it does not conflict.** The ESPHome-version tracker writes only the resolved upstream version string into [`esphome-version.json`](./esphome-version.json) - no per-invocation state (timestamps, GUIDs). Both matrix legs resolving the same PyPI version produce byte-identical content, so independent `main`/`develop` bumps never diverge into a `develop -> main` merge conflict. - **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; this repo uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form - do not reintroduce `app-id` / `CODEGEN_APP_ID`. See the upstream [README "Template - GitHub Setup"](https://github.com/ptr727/ProjectTemplate/blob/develop/README.md#template---github-setup) for the secret-setup procedure. ## Release Model @@ -195,10 +195,11 @@ Commit signing inside any container relies on the forwarded `ssh-agent` socket a This is a Docker-only repo - there is no language source tree. - `Docker/` - the multi-arch container build: [`Dockerfile`](./Docker/Dockerfile) (multi-stage, `python:3.13-slim` base, builds ESPHome wheels and runs as non-root), `entrypoint/` scripts, `Compose.yml`, and the static `README.md` published to Docker Hub. -- `.github/` - workflows (the two-layer release pipeline + the ESPHome-version tracker), Dependabot, Copilot instructions, and `esphome-version.json` (the committed upstream-version state file the Docker build reads). +- `.github/` - workflows (the two-layer release pipeline + the ESPHome-version tracker), Dependabot, and Copilot instructions. - `.devcontainer/` - the single ESPHome runtime devcontainer. - `.vscode/`, `ESPHome-NonRoot.code-workspace` - editor config. - `version.json` - NBGV version floor (drives the GitHub release tag and `LABEL_VERSION`; independent of the ESPHome image tag). +- `esphome-version.json` - the committed upstream ESPHome version pin, maintained by the tracker and read by the Docker build for the image tag + `ESPHOME_VERSION` build-arg. Sits beside `version.json` (both are root-level version sources the build reads), not under `.github/` (which holds GitHub-platform config). ## Template Lineage and Re-Syncing diff --git a/.github/esphome-version.json b/esphome-version.json similarity index 100% rename from .github/esphome-version.json rename to esphome-version.json From 5e0583ae4bb13eb13f25df3ab1c444140a93b8f8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 20 Jun 2026 14:57:34 -0700 Subject: [PATCH 3/8] Re-trigger CI on current head Forces a fresh pull_request merge-ref so reusable workflows resolve from the current head (the prior run executed a stale merge-ref after a close/reopen). No content change; squashed away on merge to develop. From a88b55d7ecafb34962af2a18668e6f5429a5e010 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 20 Jun 2026 15:03:04 -0700 Subject: [PATCH 4/8] Address Copilot review: harden smoke gating, fix tracker concurrency and doc cadence - build-docker-task: self-gate push and cache-to on !smoke (defense-in-depth) - check-esphome-version: ref-independent concurrency group so the scheduled run and a manual dispatch can't race the esphome-version-bump/* branches - Docker/README.md: describe the two-phase publish cadence accurately - publish-release.yml: base-image example matches python:3.13-slim --- .github/workflows/build-docker-task.yml | 21 ++++++++++++--------- .github/workflows/check-esphome-version.yml | 7 ++++++- .github/workflows/publish-release.yml | 2 +- Docker/README.md | 2 +- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 7d914d1..68db889 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -100,7 +100,10 @@ jobs: # copies only `entrypoint/`); the ESPHome version is supplied as a # build-arg, so the state file does not need to be in the context. context: ./Docker - push: ${{ inputs.push }} + # Self-enforce "smoke never publishes" at the task level too (the + # caller also gates on !smoke), so a future miswired caller cannot + # publish from a smoke run. + push: ${{ inputs.push && !inputs.smoke }} file: ./Docker/Dockerfile # `main` publishes the moving `:latest` tag plus the immutable pinned # ESPHome version tag (e.g. `:2025.5.0`) so consumers can pin an exact @@ -114,17 +117,17 @@ jobs: # Branch-scoped registry cache. READ both branches' caches - the # layers are nearly identical - so a main build can seed from # develop's cache and vice versa - but WRITE only this branch's own - # tag, and only when actually pushing. Gating the export on - # `inputs.push` (not just `!smoke`) means a non-publishing build never - # writes the shared registry cache or needs Docker Hub write creds - - # smoke builds (always push=false) are covered too. Branch-scoping is - # what lets the publisher's weekly matrix build main and develop - # concurrently in one run without the two legs overwriting a single - # shared cache (destroying hit rates). + # tag, and only when actually publishing (push and not smoke). Gating + # the export on `inputs.push && !inputs.smoke` means a non-publishing + # or smoke build never writes the shared registry cache or needs + # Docker Hub write creds. Branch-scoping is what lets the publisher's + # weekly matrix build main and develop concurrently in one run without + # the two legs overwriting a single shared cache (destroying hit + # rates). cache-from: | type=registry,ref=docker.io/ptr727/esphome-nonroot:buildcache-main type=registry,ref=docker.io/ptr727/esphome-nonroot:buildcache-develop - cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/esphome-nonroot:buildcache-{0},mode=max,ignore-error=true', inputs.branch) || '' }} + cache-to: ${{ inputs.push && !inputs.smoke && format('type=registry,ref=docker.io/ptr727/esphome-nonroot:buildcache-{0},mode=max,ignore-error=true', inputs.branch) || '' }} build-args: | LABEL_VERSION=${{ needs.get-version.outputs.SemVer2 }} ESPHOME_VERSION=${{ steps.esphome.outputs.version }} diff --git a/.github/workflows/check-esphome-version.yml b/.github/workflows/check-esphome-version.yml index 409811d..76620cb 100644 --- a/.github/workflows/check-esphome-version.yml +++ b/.github/workflows/check-esphome-version.yml @@ -17,7 +17,12 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # Ref-independent group: the scheduled run (github.ref = default branch) and a + # manual dispatch (any ref) both matrix over main + develop and write the same + # `esphome-version-bump/*` branches, so a ref-scoped group would let them run + # concurrently and race on those branches. Mirrors homeassistant-purpleair's + # check-ha-version.yml. + group: ${{ github.workflow }} cancel-in-progress: true permissions: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4d963af..7f26088 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -8,7 +8,7 @@ on: # Weekly full build/publish of both branches on Mondays at 02:00 UTC. # This is the guaranteed publisher in the default two-phase model: routine # merges only smoke-test, and this scheduled run republishes everything - # (also refreshing the Docker base image, e.g. `ubuntu:rolling`). + # (also refreshing the Docker base image, e.g. `python:3.13-slim`). - cron: '0 2 * * MON' # Real publishes (schedule, dispatch, or push when PUBLISH_ON_MERGE is set) diff --git a/Docker/README.md b/Docker/README.md index f87db6b..8251a53 100644 --- a/Docker/README.md +++ b/Docker/README.md @@ -10,7 +10,7 @@ Licensed under the [MIT License](https://github.com/ptr727/ESPHome-NonRoot/blob/ ## Project -Code and pipeline are on [GitHub](https://github.com/ptr727/ESPHome-NonRoot). Docker images are published on [Docker Hub](https://hub.docker.com/r/ptr727/esphome-nonroot) and rebuilt on each upstream ESPHome release and weekly. +Code and pipeline are on [GitHub](https://github.com/ptr727/ESPHome-NonRoot). Docker images are published on [Docker Hub](https://hub.docker.com/r/ptr727/esphome-nonroot). A new upstream ESPHome release is picked up by an automated version-bump pull request and published on the next scheduled (weekly) or manually triggered release run. ## Docker Tags From dd17493df1875a4e0ca8bac4958d4e141c54b3a8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 20 Jun 2026 15:04:53 -0700 Subject: [PATCH 5/8] Keep esphome-version.json CRLF in tracker bump commits jq emits LF; .editorconfig requires CRLF for *.json and .gitattributes disables EOL normalization, so convert the rewritten file to CRLF before committing or every bump PR would flip it to LF. --- .github/workflows/check-esphome-version.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/check-esphome-version.yml b/.github/workflows/check-esphome-version.yml index 76620cb..2551fc5 100644 --- a/.github/workflows/check-esphome-version.yml +++ b/.github/workflows/check-esphome-version.yml @@ -82,6 +82,10 @@ jobs: # Rewrite only the version field, preserving the $comment field. tmp="$(mktemp)" jq --arg v "$latest" '.version = $v' esphome-version.json > "$tmp" + # jq emits LF, but .editorconfig requires CRLF for *.json and + # .gitattributes (* -text) disables git EOL normalization - so convert + # to CRLF before committing or every bump PR would flip the file to LF. + sed -i 's/$/\r/' "$tmp" mv "$tmp" esphome-version.json { echo "changed=true" From aa84cb4da469c178166aa7b7c555441075311423 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 20 Jun 2026 17:55:48 -0700 Subject: [PATCH 6/8] Make workflow comments describe the code, not cross-repo lineage or rules - Drop sibling-repo references (other repos are not relevant to a reviewer) - Drop AGENTS.md/ruleset rule-citations from authored comments; state the intrinsic reason instead - Correct the Docker Hub login comment: this is a public repo, so describe the authenticated-pull logic and its real fork-PR consequence rather than a false private-repo justification --- .github/workflows/build-docker-task.yml | 13 +++----- .github/workflows/build-release-task.yml | 8 ++--- .github/workflows/check-esphome-version.yml | 32 +++++++++---------- .../workflows/publish-docker-readme-task.yml | 5 ++- .github/workflows/publish-release.yml | 14 ++++---- AGENTS.md | 4 +-- 6 files changed, 34 insertions(+), 42 deletions(-) diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 68db889..8d1c40c 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -79,14 +79,11 @@ jobs: with: platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - # Always login to Docker Hub, not just on push, to benefit from higher - # rate limits with a Docker subscription for pulls and cache reads on - # every build (including smoke). This is a CONSCIOUS choice over gating - # login on `inputs.push`: the trade-off is that fork PRs without access - # to the Docker Hub secrets cannot run the Docker smoke build. - # Acceptable here because the repo is private and PRs are same-repo; a - # public derived project that accepts fork PRs may prefer to gate this - # step on `inputs.push`. + # Log in to Docker Hub on every build, including non-pushing smoke builds, + # so base-image pulls and registry cache reads are authenticated and get + # higher rate limits. Because the login uses repository secrets, a smoke + # build that cannot read them - a pull request from a fork - fails at this + # step and so cannot run the Docker build. - name: Login to Docker Hub step uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index e1887af..cac130b 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -33,11 +33,9 @@ on: required: false type: boolean default: false - # Per-target presence gate. Default true (build the Docker image). A PR - # smoke run sets this from the paths-filter so the build only runs when - # the Docker context changed. (This repo ships only the Docker target; - # the template's nuget/pypi/executable gates and jobs were removed per - # AGENTS.md "Release Model" per-target subsetting.) + # Presence gate for the Docker build. Default true (build it). A PR smoke + # run sets this from the paths-filter so the build only runs when the + # Docker context changed. Docker is the only build target this repo ships. enable_docker: required: false type: boolean diff --git a/.github/workflows/check-esphome-version.yml b/.github/workflows/check-esphome-version.yml index 2551fc5..4edd8da 100644 --- a/.github/workflows/check-esphome-version.yml +++ b/.github/workflows/check-esphome-version.yml @@ -1,12 +1,13 @@ name: Check ESPHome version action -# Upstream-release tracker. Mirrors ptr727/homeassistant-purpleair's -# check-ha-version.yml: it does NOT dispatch a build. It resolves the latest -# upstream ESPHome version from PyPI, writes it into the committed state file -# esphome-version.json, and opens a rolling, App-authored, signed PR -# that merge-bot auto-merges. The pinned version then ships on the next publish -# (weekly schedule / manual dispatch / PUBLISH_ON_MERGE). build-docker-task.yml -# reads the same state file for the image tag and the ESPHOME_VERSION build-arg. +# Upstream-release tracker. It does NOT dispatch a build directly: it resolves +# the latest upstream ESPHome version from PyPI, writes it into the committed +# state file esphome-version.json, and opens a rolling, App-authored, signed PR +# that merge-bot auto-merges. Routing the bump through a PR (rather than a build +# dispatch) keeps the pinned version reviewable and version-controlled, and lets +# the normal publish flow ship it on the next run (weekly schedule / manual +# dispatch / PUBLISH_ON_MERGE). build-docker-task.yml reads the same state file +# for the image tag and the ESPHOME_VERSION build-arg. on: schedule: @@ -20,8 +21,7 @@ concurrency: # Ref-independent group: the scheduled run (github.ref = default branch) and a # manual dispatch (any ref) both matrix over main + develop and write the same # `esphome-version-bump/*` branches, so a ref-scoped group would let them run - # concurrently and race on those branches. Mirrors homeassistant-purpleair's - # check-ha-version.yml. + # concurrently and race on those branches. group: ${{ github.workflow }} cancel-in-progress: true @@ -36,11 +36,11 @@ jobs: strategy: fail-fast: false matrix: - # Dual-target: the pinned ESPHome version is shipped image content (main - # builds the :latest and : tags), so main and develop are - # tracked independently - like Dependabot - rather than develop-only. - # See AGENTS.md "Branching Model". The head/base pairing is enforced by - # merge-bot's merge-esphome-version-bump job. + # Track both branches: the pinned ESPHome version is shipped image + # content (main builds the :latest and : tags consumers pull + # directly), so each branch needs the current version rather than + # waiting for develop to flow into main. merge-bot's + # merge-esphome-version-bump job enforces the head/base pairing. branch: [main, develop] permissions: contents: write @@ -98,8 +98,8 @@ jobs: # Single rolling branch per base - at most one bump PR open per branch. # peter-evans force-pushes the latest pending version to the branch on # every tick, so the PR refreshes in place. sign-commits creates the - # commit via the API so it is signed (the signed-commits ruleset - # requires it). The App token makes the PR App-authored. + # commit through the GitHub API so it is cryptographically signed. The + # App token makes the PR App-authored so it triggers CI and merge-bot. uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/publish-docker-readme-task.yml b/.github/workflows/publish-docker-readme-task.yml index 7a8fbd8..eb8d83c 100644 --- a/.github/workflows/publish-docker-readme-task.yml +++ b/.github/workflows/publish-docker-readme-task.yml @@ -1,9 +1,8 @@ name: Publish docker hub readme task # Pushes the static Docker Hub repository overview from Docker/README.md. -# Mirrors ptr727/NxWitness's publish-docker-readme-task.yml. Called from -# publish-release.yml after a real publish (never on smoke); the README tracks -# main so it matches the :latest image. +# Called from publish-release.yml after a real publish (never on smoke); the +# README tracks main so the published overview matches the :latest image. on: workflow_call: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 7f26088..c989ea9 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -90,17 +90,15 @@ jobs: ref: ${{ matrix.branch }} branch: ${{ matrix.branch }} smoke: false - # Push to GitHub (release) and Docker Hub (image). This repo ships only - # the Docker target; the template's nuget/pypi flags and jobs were - # removed per AGENTS.md "Release Model" per-target subsetting. + # Publish the GitHub release and push the Docker image. Docker is the only + # delivery target this repo ships. github: true dockerhub: true - # Push the static Docker Hub repository overview after a real publish. - # Mirrors ptr727/NxWitness's `publish-docker-readme-task.yml` and is wired - # like `date-badge` below; the README tracks `main` so it matches the - # `:latest` image. Runs only on real publishes (gated on `setup.publish`), - # never on smoke. + # Push the static Docker Hub repository overview after a real publish, wired + # like `date-badge` below; the README tracks `main` so the published overview + # matches the `:latest` image. Runs only on real publishes (gated on + # `setup.publish`), never on smoke. docker-readme: name: Publish docker hub readme job needs: [setup, publish] diff --git a/AGENTS.md b/AGENTS.md index 8eb9db1..1e4c634 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Treat this file as authoritative; don't restate its rules elsewhere. - **Configuring branch protection: don't hand-build the rules.** Reconstructing the rules by hand is error-prone and has gone wrong on past ports. First delete **all** legacy classic branch-protection rules and any stray rulesets (this repo uses rulesets *only*), then create **exactly two rulesets named `develop` and `main`** by exporting the template's two rulesets and re-importing them via `gh api -X POST .../rulesets` (`gh ruleset` is read-only). The names are load-bearing - this file and the workflows reference them. Full export/import + brownfield (`Require signed commits` vs unsigned history) procedure: [upstream README "Rules / Rulesets"](https://github.com/ptr727/ProjectTemplate/blob/develop/README.md#template---github-setup). - **Bots (Dependabot and the ESPHome-version tracker) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch), and [`.github/workflows/check-esphome-version.yml`](./.github/workflows/check-esphome-version.yml) runs as a matrix over both branches, opening rolling bump PRs on branches `esphome-version-bump/main` and `esphome-version-bump/develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. - **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-esphome-version-bump` jobs only fire on `opened` / `reopened` events (auto-merge is enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` event with an actor that isn't the same bot), the merge-bot's `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The maintainer's commits stay in the PR but won't auto-merge with the bot's content; re-enable auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. -- **Why parallel dual-target rather than develop-only with eventual flow-through:** the published image (`:latest` and the `:` tag) is built from `main`, and pull consumers fetch it directly. A develop-only model would leave `main` shipping a stale ESPHome version during long-running develop features. The upstream ESPHome version *is* the shipped content, so both branches need it fresh on their own cadence (this differs from `homeassistant-purpleair`, whose HA-version tracker targets `develop` only because there it drives CI test versions, not the shipped artifact). +- **Why parallel dual-target rather than develop-only with eventual flow-through:** the published image (`:latest` and the `:` tag) is built from `main`, and pull consumers fetch it directly. A develop-only model would leave `main` shipping a stale ESPHome version during long-running develop features. The upstream ESPHome version *is* the shipped content (it determines the image tag and the installed package), so both branches need it kept fresh on their own cadence rather than develop-only with eventual flow-through. - **Dual-target tracker output is deterministic, so it does not conflict.** The ESPHome-version tracker writes only the resolved upstream version string into [`esphome-version.json`](./esphome-version.json) - no per-invocation state (timestamps, GUIDs). Both matrix legs resolving the same PyPI version produce byte-identical content, so independent `main`/`develop` bumps never diverge into a `develop -> main` merge conflict. - **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; this repo uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the same form - do not reintroduce `app-id` / `CODEGEN_APP_ID`. See the upstream [README "Template - GitHub Setup"](https://github.com/ptr727/ProjectTemplate/blob/develop/README.md#template---github-setup) for the secret-setup procedure. @@ -203,7 +203,7 @@ This is a Docker-only repo - there is no language source tree. ## Template Lineage and Re-Syncing -This repo is **derived from [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate)** (develop branch). It is past initial bootstrap; the ongoing concern is keeping the carried contract in sync (below). The orchestration workflows (`publish-release.yml`, `get-version-task.yml`, `build-datebadge-task.yml`, the `get-version` + `github-release` jobs in `build-release-task.yml`, the aggregator shape of `test-pull-request.yml`) and the mandatory shared files are synced from the template; the `build-docker-task.yml` leaf, the ESPHome-version tracker, and the Docker Hub README are owned here (the tracker mirrors `homeassistant-purpleair`'s `check-ha-version.yml`; the Docker Hub README task mirrors `NxWitness`'s `publish-docker-readme-task.yml`). +This repo is **derived from [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate)** (develop branch). It is past initial bootstrap; the ongoing concern is keeping the carried contract in sync (below). The orchestration workflows (`publish-release.yml`, `get-version-task.yml`, `build-datebadge-task.yml`, the `get-version` + `github-release` jobs in `build-release-task.yml`, the aggregator shape of `test-pull-request.yml`) and the mandatory shared files are synced from the template; the `build-docker-task.yml` leaf, the ESPHome-version tracker, and the Docker Hub README task are owned here. ### Files and Sections Derived Repos Must Carry Verbatim From 5be4ae9f7d74bc5e581c09e8a519a942ea930435 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 20 Jun 2026 17:58:58 -0700 Subject: [PATCH 7/8] Gate Docker Hub login on push; drop design-time cross-target comment - Login only when publishing so fork PR smoke builds (no secret access) can run - Rephrase the no-op-republish comment to describe the skip behavior without comparing to NuGet/PyPI registries this repo does not ship --- .github/workflows/build-docker-task.yml | 11 ++++++----- .github/workflows/build-release-task.yml | 12 +++++------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 8d1c40c..654b398 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -79,12 +79,13 @@ jobs: with: platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - # Log in to Docker Hub on every build, including non-pushing smoke builds, - # so base-image pulls and registry cache reads are authenticated and get - # higher rate limits. Because the login uses repository secrets, a smoke - # build that cannot read them - a pull request from a fork - fails at this - # step and so cannot run the Docker build. + # Log in to Docker Hub only when publishing. A non-pushing build - a PR + # smoke build - needs no credentials: it reads the public registry build + # cache anonymously and never pushes. Gating the login here is what lets a + # pull request from a fork, which has no access to the repository secrets, + # still run the Docker build. - name: Login to Docker Hub step + if: ${{ inputs.push }} uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index cac130b..9f9e14d 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -111,13 +111,11 @@ jobs: path: ./Publish # The weekly publisher re-runs even when a branch has no new commits, so - # NBGV can produce a SemVer2 that was already released. GitHub release - # creation has no built-in skip-duplicate (unlike NuGet's - # `--skip-duplicate` and PyPI's `skip-existing`), and re-publishing an - # unchanged version is exactly the churn the two-phase model avoids - so - # skip the release step when a release for this tag already exists. The - # Docker mutable tags (`latest`/`develop`) and base-image refresh still - # happen regardless, so security-only rebuilds still ship. + # NBGV can produce a SemVer2 that was already released. Skip the release + # step when a release for this tag already exists, so a no-op republish + # does not recreate it. The Docker mutable tags (`latest`/`develop`) and + # base-image refresh still happen regardless, so rebuilds that only pick + # up a new base image still ship. - name: Check for existing release step id: release-exists env: From 56403f36bbc6a2da79b61bc6e279c8f173a85363 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 20 Jun 2026 18:10:42 -0700 Subject: [PATCH 8/8] Trim workflow comments to concise current-state context Make comments short and reviewer-focused: describe what the code does, drop historic/design background, cross-project references, and rule citations. Remove the dead semver-major NuGet auto-merge guard and its metadata step (no NuGet ecosystem here; behavior unchanged). --- .github/dependabot.yml | 8 +- .github/workflows/build-datebadge-task.yml | 5 +- .github/workflows/build-docker-task.yml | 57 +++------- .github/workflows/build-release-task.yml | 77 +++---------- .github/workflows/check-esphome-version.yml | 45 +++----- .github/workflows/get-version-task.yml | 31 +---- .github/workflows/merge-bot-pull-request.yml | 107 +++--------------- .../workflows/publish-docker-readme-task.yml | 8 +- .github/workflows/publish-release.yml | 49 +++----- .github/workflows/test-pull-request.yml | 49 +++----- 10 files changed, 96 insertions(+), 340 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8b0d46d..76421b7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,7 @@ # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates -# https://containers.dev/guide/dependabot # -# Every ecosystem appears twice - once with target-branch "main" and once with -# "develop" - so Dependabot keeps both branches current independently of the -# develop -> main release cadence (main ships the published image directly). -# The merge-bot dispatches the right merge method per base; security PRs always -# open against main. See AGENTS.md "Branching Model". +# Each ecosystem targets both main and develop so neither branch goes stale +# between releases. version: 2 updates: diff --git a/.github/workflows/build-datebadge-task.yml b/.github/workflows/build-datebadge-task.yml index 7b37e73..6be2753 100644 --- a/.github/workflows/build-datebadge-task.yml +++ b/.github/workflows/build-datebadge-task.yml @@ -3,10 +3,7 @@ name: Build BYOB date badge task on: workflow_call: inputs: - # Logical branch this badge run is for. The badge only updates on - # `main`; the publisher passes the branch explicitly so a scheduled - # run building `develop` doesn't try to write the main badge. Required - # (no `github.ref_name` fallback) so the gate can't silently misfire. + # Branch this run is for; the badge updates on `main` only. branch: required: true type: string diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index 654b398..8745778 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -3,27 +3,19 @@ name: Build Docker image task on: workflow_call: inputs: - # Input to control whether to push the Docker image to Docker Hub push: required: false type: boolean default: false - # Git ref to check out / version (empty = default checkout ref). ref: required: false type: string default: '' - # Logical branch driving the image tags (`main` => `latest` + the pinned - # ESPHome version, anything else => `develop`). Required (no - # `github.ref_name` fallback): the publisher builds develop from a run - # whose `github.ref_name` is `main`, so a silent fallback would mistag it. - # The orchestrator always passes it explicitly. + # Drives the image tags: main => latest + pinned version, else develop. branch: required: true type: string - # Smoke mode: build `linux/amd64` only (no QEMU/arm64), never push, and - # skip the shared registry `cache-to` so PR builds don't pollute the - # release buildcache. Used for fast PR feedback. + # amd64 only, no push, no shared cache write - fast PR feedback. smoke: required: false type: boolean @@ -50,11 +42,8 @@ jobs: with: ref: ${{ inputs.ref }} - # Read the committed upstream ESPHome version from the state file the - # tracker (check-esphome-version.yml) maintains. This is a reviewed, - # version-controlled value - not a live PyPI fetch - so the build is - # deterministic and reproducible for a given commit. It drives both the - # image tag and the ESPHOME_VERSION build-arg that pins the wheel build. + # Pinned ESPHome version from the committed state file; drives the image + # tag and the ESPHOME_VERSION build-arg. - name: Get ESPHome version step id: esphome run: | @@ -66,8 +55,7 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" - # QEMU only exists to emulate arm64. Smoke builds are amd64-only, so - # skip it entirely to save the emulation setup cost. + # QEMU emulates arm64; skip for amd64-only smoke builds. - name: Setup QEMU step if: ${{ !inputs.smoke }} uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 @@ -79,11 +67,8 @@ jobs: with: platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - # Log in to Docker Hub only when publishing. A non-pushing build - a PR - # smoke build - needs no credentials: it reads the public registry build - # cache anonymously and never pushes. Gating the login here is what lets a - # pull request from a fork, which has no access to the repository secrets, - # still run the Docker build. + # Log in only when publishing; smoke/PR builds read the public cache + # anonymously, so fork PRs (no secret access) can still build. - name: Login to Docker Hub step if: ${{ inputs.push }} uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 @@ -94,34 +79,20 @@ jobs: - name: Docker build and push step uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: - # The Docker build context is the `Docker/` directory (the Dockerfile - # copies only `entrypoint/`); the ESPHome version is supplied as a - # build-arg, so the state file does not need to be in the context. + # Context is Docker/ (the Dockerfile only copies entrypoint/). context: ./Docker - # Self-enforce "smoke never publishes" at the task level too (the - # caller also gates on !smoke), so a future miswired caller cannot - # publish from a smoke run. + # Also gate on !smoke so a smoke run can never publish. push: ${{ inputs.push && !inputs.smoke }} file: ./Docker/Dockerfile - # `main` publishes the moving `:latest` tag plus the immutable pinned - # ESPHome version tag (e.g. `:2025.5.0`) so consumers can pin an exact - # upstream version. `develop` publishes only the moving `:develop` - # tag. On `develop` the second entry is an empty line that - # build-push-action ignores. + # main: latest + the pinned version tag; develop: develop only (the + # second line is empty off main, which build-push-action ignores). tags: | docker.io/ptr727/esphome-nonroot:${{ inputs.branch == 'main' && 'latest' || 'develop' }} ${{ inputs.branch == 'main' && format('docker.io/ptr727/esphome-nonroot:{0}', steps.esphome.outputs.version) || '' }} platforms: ${{ inputs.smoke && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - # Branch-scoped registry cache. READ both branches' caches - the - # layers are nearly identical - so a main build can seed from - # develop's cache and vice versa - but WRITE only this branch's own - # tag, and only when actually publishing (push and not smoke). Gating - # the export on `inputs.push && !inputs.smoke` means a non-publishing - # or smoke build never writes the shared registry cache or needs - # Docker Hub write creds. Branch-scoping is what lets the publisher's - # weekly matrix build main and develop concurrently in one run without - # the two legs overwriting a single shared cache (destroying hit - # rates). + # Read both branches' caches (near-identical layers); write only this + # branch's, and only when publishing. Branch-scoped so the publisher's + # main/develop legs don't clobber one shared cache. cache-from: | type=registry,ref=docker.io/ptr727/esphome-nonroot:buildcache-main type=registry,ref=docker.io/ptr727/esphome-nonroot:buildcache-develop diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 9f9e14d..16e31ad 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -3,39 +3,28 @@ name: Build project release task on: workflow_call: inputs: - # Input to control whether to create a GitHub release github: required: false type: boolean default: false - # Input to control whether to push the docker image to Docker Hub dockerhub: required: false type: boolean default: false - # Git ref to check out / version (empty = default checkout ref). ref: required: false type: string default: '' - # Logical branch driving config / tags / prerelease for every target. - # Required (no `github.ref_name` fallback): the publisher builds both - # `main` and `develop` from one run whose `github.ref_name` is `main`, - # so a silent fallback would mislabel the develop leg. Every caller - # passes it explicitly; a missing value should fail loudly. + # Drives tags/prerelease: main => latest/non-prerelease, else develop. branch: required: true type: string - # Smoke mode: reduced, never-published build for fast PR feedback. - # Forwarded to every target; also hard-disables every push below so a - # smoke run can never publish regardless of the publish flags. + # Reduced, never-published build for PR feedback; disables every push below. smoke: required: false type: boolean default: false - # Presence gate for the Docker build. Default true (build it). A PR smoke - # run sets this from the paths-filter so the build only runs when the - # Docker context changed. Docker is the only build target this repo ships. + # Docker build gate; a PR smoke run sets it from the paths-filter. enable_docker: required: false type: boolean @@ -57,52 +46,31 @@ jobs: uses: ./.github/workflows/build-docker-task.yml secrets: inherit with: - # Pin to the exact commit get-version resolved (immutable), not the - # possibly-moving branch ref: the publisher passes a branch name, and a - # commit landing mid-run could otherwise build artifacts from a different - # commit than the one the release tag (also GitCommitId) points at. + # Pin to the built commit so the image matches the release tag even if the + # branch advances mid-run. ref: ${{ needs.get-version.outputs.GitCommitId }} branch: ${{ inputs.branch }} smoke: ${{ inputs.smoke }} - # Conditional push to Docker Hub - never on a smoke build. push: ${{ inputs.dockerhub && !inputs.smoke }} github-release: name: Publish GitHub release job - # `&& !inputs.smoke` enforces the "smoke never publishes" guarantee at the - # job level too (matching the `&& !inputs.smoke` push gates above), so a - # smoke caller that also set `github: true` still can't create a release. + # Also gate on !smoke so a smoke run can't create a release. if: ${{ inputs.github && !inputs.smoke }} runs-on: ubuntu-latest needs: [get-version, build-docker] steps: - # Check out the exact built commit (NBGV `GitCommitId`), not the - # possibly-moving `inputs.ref` branch, so the uploaded release files - # match the tag even if the branch advances mid-run. + # Check out the built commit so release files match the tag. - name: Checkout code step uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.get-version.outputs.GitCommitId }} - # Collect every release asset by the `release-asset--*` artifact - # convention rather than naming individual build jobs, so this download - # step never changes when a derived project subsets targets: dropping a - # target (delete its job + this job's `needs` entry, per AGENTS.md - # "Per-target subsetting") just leaves one fewer `release-asset-*` to - # match, and a downstream ships different files by adding/replacing a - # leaf `build-*-task.yml` that uploads under this pattern - without - # editing this step. `merge-multiple` flattens the matched artifacts into - # one `./Publish` dir, exactly as the previous per-target downloads did; - # download-artifact@v7 succeeds (downloads nothing) when the pattern - # matches zero artifacts, so a file-less release (tag + LICENSE/README - # only) still works. - # NOTE: subset releases by *deleting* the target, not by `enable_*: - # false`. `enable_*` is for smoke subsetting only - it skips a build job, - # and because `github-release` `needs` those jobs, a skipped one would - # skip this release job too (GitHub Actions: a skipped `needs` job skips - # its dependents). + # Collect any release-asset--* artifacts. The Docker build pushes + # directly and uploads none, so this matches nothing (which succeeds) and + # the release carries just the tag + LICENSE/README. - name: Download release asset artifacts step uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: @@ -110,12 +78,9 @@ jobs: merge-multiple: true path: ./Publish - # The weekly publisher re-runs even when a branch has no new commits, so - # NBGV can produce a SemVer2 that was already released. Skip the release - # step when a release for this tag already exists, so a no-op republish - # does not recreate it. The Docker mutable tags (`latest`/`develop`) and - # base-image refresh still happen regardless, so rebuilds that only pick - # up a new base image still ship. + # The weekly publisher may re-run an already-released SemVer2; skip + # recreating an existing release (Docker tags and base-image refresh still + # ship). - name: Check for existing release step id: release-exists env: @@ -134,19 +99,9 @@ jobs: echo "exists=false" >> "$GITHUB_OUTPUT" fi - # `target_commitish` MUST be set explicitly: softprops doesn't pass a - # default through, and GitHub's REST API then defaults the new tag to - # the repository's default branch (main). We pin it to NBGV's - # `GitCommitId` - the exact commit the version was computed from. This - # avoids two bugs: `github.sha` would be wrong (the publisher's branch - # matrix builds `develop` from a run whose `github.sha` is main's tip), - # and `inputs.branch` would be a moving ref (a commit landing mid-run - # could tag the release on a newer commit than the one that was built). - # The exact SHA is immutable, on the right branch, and consistent with - # both the SemVer2 tag and the uploaded artifacts. - # Skip the no-op weekly republish when the tag already exists, but always - # allow a manual `workflow_dispatch` through so it can repair/refresh a - # partially-created release for the same tag. + # Pin the tag to the built commit; without target_commitish GitHub tags + # the default branch. Skip when the tag exists, but let workflow_dispatch + # through to refresh it. - name: Create GitHub release step if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 diff --git a/.github/workflows/check-esphome-version.yml b/.github/workflows/check-esphome-version.yml index 4edd8da..9dc0f44 100644 --- a/.github/workflows/check-esphome-version.yml +++ b/.github/workflows/check-esphome-version.yml @@ -1,27 +1,19 @@ name: Check ESPHome version action -# Upstream-release tracker. It does NOT dispatch a build directly: it resolves -# the latest upstream ESPHome version from PyPI, writes it into the committed -# state file esphome-version.json, and opens a rolling, App-authored, signed PR -# that merge-bot auto-merges. Routing the bump through a PR (rather than a build -# dispatch) keeps the pinned version reviewable and version-controlled, and lets -# the normal publish flow ship it on the next run (weekly schedule / manual -# dispatch / PUBLISH_ON_MERGE). build-docker-task.yml reads the same state file -# for the image tag and the ESPHOME_VERSION build-arg. +# Tracks the upstream ESPHome PyPI release: writes the latest version into +# esphome-version.json and opens a rolling, auto-merged PR so the normal publish +# flow ships it. build-docker-task.yml reads the same file for the image tag and +# the ESPHOME_VERSION build-arg. on: schedule: - # Daily at 05:00 UTC. ESPHome publishes to PyPI on its own cadence; a daily - # check keeps the pinned version within ~24h. workflow_dispatch is available - # for urgent bumps. + # Daily; keeps the pin within ~24h of PyPI. - cron: '0 5 * * *' workflow_dispatch: concurrency: - # Ref-independent group: the scheduled run (github.ref = default branch) and a - # manual dispatch (any ref) both matrix over main + develop and write the same - # `esphome-version-bump/*` branches, so a ref-scoped group would let them run - # concurrently and race on those branches. + # Ref-independent so a scheduled run and a manual dispatch can't run together + # and race the shared esphome-version-bump/* branches. group: ${{ github.workflow }} cancel-in-progress: true @@ -36,11 +28,8 @@ jobs: strategy: fail-fast: false matrix: - # Track both branches: the pinned ESPHome version is shipped image - # content (main builds the :latest and : tags consumers pull - # directly), so each branch needs the current version rather than - # waiting for develop to flow into main. merge-bot's - # merge-esphome-version-bump job enforces the head/base pairing. + # Both branches: the pinned version is the shipped image content, so + # main can't wait for develop to flow in. branch: [main, develop] permissions: contents: write @@ -49,9 +38,7 @@ jobs: steps: - name: Generate GitHub App token step - # App-authored PR so it triggers PR workflows (PRs authored by the - # default GITHUB_TOKEN do not) and merge-bot's actor guard picks it up. - # client-id per actions/create-github-app-token v3+. + # App-authored so the PR triggers CI and merge-bot (GITHUB_TOKEN does not). id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -79,12 +66,9 @@ jobs: echo "changed=false" >> "$GITHUB_OUTPUT" exit 0 fi - # Rewrite only the version field, preserving the $comment field. tmp="$(mktemp)" jq --arg v "$latest" '.version = $v' esphome-version.json > "$tmp" - # jq emits LF, but .editorconfig requires CRLF for *.json and - # .gitattributes (* -text) disables git EOL normalization - so convert - # to CRLF before committing or every bump PR would flip the file to LF. + # Keep CRLF (jq emits LF) so bumps don't churn line endings. sed -i 's/$/\r/' "$tmp" mv "$tmp" esphome-version.json { @@ -95,11 +79,8 @@ jobs: - name: Create version bump pull request step if: ${{ steps.resolve.outputs.changed == 'true' }} - # Single rolling branch per base - at most one bump PR open per branch. - # peter-evans force-pushes the latest pending version to the branch on - # every tick, so the PR refreshes in place. sign-commits creates the - # commit through the GitHub API so it is cryptographically signed. The - # App token makes the PR App-authored so it triggers CI and merge-bot. + # Rolling branch per base (one open PR each); sign-commits signs the + # commit via the API. uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml index aa20447..8f29461 100644 --- a/.github/workflows/get-version-task.yml +++ b/.github/workflows/get-version-task.yml @@ -3,17 +3,12 @@ name: Get version information task on: workflow_call: inputs: - # Git ref to check out and version. Empty string falls back to the - # caller's default checkout ref (`github.ref`), preserving the - # original behavior. The publisher passes an explicit branch so a - # scheduled run - which always reports `github.ref` as the default - # branch - can still compute NBGV versions for `develop` too. + # Ref to check out and version; empty uses the caller's checkout ref. ref: required: false type: string default: '' outputs: - # Version information outputs SemVer2: value: ${{ jobs.get-version.outputs.SemVer2 }} AssemblyVersion: @@ -22,9 +17,8 @@ on: value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} AssemblyInformationalVersion: value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} - # Full SHA of the commit NBGV computed the version from. Used to pin the - # GitHub release tag to the exact built commit (immutable), rather than a - # moving branch ref. + # Commit SHA the version was computed from; pins the release tag to the + # exact built commit. GitCommitId: value: ${{ jobs.get-version.outputs.GitCommitId }} @@ -53,23 +47,8 @@ jobs: ref: ${{ inputs.ref }} fetch-depth: 0 - # `dotnet/nbgv` is intentionally floated on `master` rather than - # pinned to a commit SHA - a deliberate deviation from the - # AGENTS.md "pin third-party actions to a commit SHA" rule, - # documented here so the deviation isn't accidentally "fixed" by - # a future reviewer. Justification: - # - The upstream tag stream is effectively dormant - the latest - # tag `v0.5.1` lags well behind `master` and fixes accumulate - # on `master` between tag bumps. - # - Dependabot's GitHub Actions ecosystem tracks tagged - # releases. A SHA pinned to a post-`v0.5.1` `master` commit - # would either receive no Dependabot updates (silently stale) - # or get an attempted downgrade PR to `v0.5.1`'s SHA. Neither - # beats just floating on `master`. - # - Upstream owner is Microsoft (`dotnet/`), so the - # "tag-/branch-retargeting risk" the AGENTS.md rule guards - # against is materially lower than for a random author. - # Revisit if `dotnet/nbgv` resumes regular tagged releases. + # Floated on `master` (not SHA-pinned): upstream tags lag `master`, so a + # pin would only draw spurious Dependabot downgrade PRs. - name: Run Nerdbank.GitVersioning tool step id: nbgv uses: dotnet/nbgv@master diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 17cc7d2..eb448d2 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,57 +1,19 @@ name: Merge bot pull request action -# Three-job model: -# 1. `merge-dependabot` / `merge-esphome-version-bump` run on `opened` -# and `reopened` events only. They enable auto-merge via -# `gh pr merge --auto` once per PR. Restricting to open/reopen -# (skipping `synchronize`) is what makes step 3 below stick - if -# these jobs re-ran on every `synchronize`, they'd undo a -# maintainer-triggered disable. -# 2. The merge method (`--squash` vs `--merge`) is dispatched by a -# `case` statement on `pull_request.base.ref` so the form matches -# each branch's ruleset (develop = squash-only, main = merge-only, -# see AGENTS.md "Branching Model"). Both Dependabot and the ESPHome -# version tracker open parallel PRs against both branches; Dependabot -# security updates always target `main` and flow through the same -# code path. -# 3. `disable-auto-merge-on-maintainer-push` runs on `synchronize` -# events against bot-authored PRs when the event actor is NOT the -# same bot - i.e. a maintainer pushed commits to a bot PR. It -# calls `gh pr merge --disable-auto` so the maintainer's commits -# don't auto-merge along with the bot's content. The maintainer -# re-enables auto-merge manually (UI or `gh pr merge --auto`) -# when ready. -# -# Token strategy: -# Every job uses an App token (`actions/create-github-app-token`). -# The resulting push is committed by the App, which fires downstream -# workflows on develop and main. `GITHUB_TOKEN`-authored pushes are -# blocked from triggering further workflow runs by GitHub's recursion -# guard, which would silently skip `publish-release.yml` on the merge -# commit. The App-token path also removes the close/reopen dance -# previously used by bot PRs created under `GITHUB_TOKEN` to nudge -# the auto-merge workflow. The disable job needs an App token too: -# even though the event actor is a maintainer, the workflow context -# on a Dependabot PR runs with Dependabot's restricted secrets -# regardless of actor, so plain `GITHUB_TOKEN` would be read-only. +# merge-dependabot / merge-esphome-version-bump enable auto-merge once, on +# opened/reopened only, so a later maintainer push can disable it without these +# re-enabling. disable-auto-merge-on-maintainer-push turns it off when a +# maintainer pushes to a bot PR. The merge method follows the base branch +# (squash on develop, merge on main). All jobs use an App token: App-authored +# merges trigger downstream workflows (GITHUB_TOKEN merges do not), and the +# default token is read-only on a Dependabot PR. on: pull_request: types: [opened, reopened, synchronize] -# `cancel-in-progress: false` is load-bearing. The three-job model -# (enable on opened/reopened, disable on maintainer-triggered -# synchronize) relies on those events running to completion in arrival -# order. With cancel-in-progress: true, a fast follow-up synchronize -# (e.g. a Dependabot rebase right after PR open) would cancel the -# in-flight `opened` run before it reached `gh pr merge --auto`, and -# the new synchronize run skips the enable jobs (opened/reopened -# filter), leaving auto-merge never enabled. Queueing instead of -# cancelling makes the final state deterministic: opened enables, -# then any subsequent synchronize disables (if maintainer) or no-ops -# (if bot). Action-aware grouping has its own race (opened finishing -# after a maintainer synchronize would re-enable auto-merge), so we -# keep a single group and just disable cancellation. +# Queue, don't cancel: opened/synchronize events must run in order, or a +# cancelled `opened` run could leave auto-merge never enabled. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -61,12 +23,7 @@ jobs: merge-dependabot: name: Merge dependabot pull request job runs-on: ubuntu-latest - # Restrict to Dependabot PRs that originate from this repository, not - # a fork. Only runs on `opened` / `reopened` events so the auto-merge - # enable happens once per PR; the `disable-auto-merge-on-maintainer-push` - # job below is what disables auto-merge when a maintainer pushes to a - # Dependabot branch. Skipping `synchronize` here is what keeps that - # disable sticky. + # Dependabot PRs from this repo (not forks), on opened/reopened only. if: >- (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login == 'dependabot[bot]' && @@ -84,19 +41,7 @@ jobs: client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} - - name: Get dependabot metadata step - id: metadata - uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - # Skip semver-major NuGet bumps: majors can build cleanly but break - # runtime behavior, so they should land via human review. Other - # ecosystems' majors (github-actions, uv) are usually safe and merge. - name: Merge pull request step - if: >- - (steps.metadata.outputs.package-ecosystem != 'nuget') || - (steps.metadata.outputs.update-type != 'version-update:semver-major') run: | set -euo pipefail case "${{ github.event.pull_request.base.ref }}" in @@ -115,18 +60,8 @@ jobs: merge-esphome-version-bump: name: Merge ESPHome version bump pull request job runs-on: ubuntu-latest - # Restrict to ESPHome-version-bump PRs that originate from the App in - # this repository. The tracker (check-esphome-version.yml) runs in a - # matrix over `main` and `develop`, so two head refs are valid: - # `esphome-version-bump/main` (always targets `main`) and - # `esphome-version-bump/develop` (always targets `develop`). The - # head/base pairing is enforced strictly so a misconfigured workflow - # can't, for example, sneak a develop bump into `main`. - # Only runs on `opened` / `reopened` events so the auto-merge enable - # happens once per PR; the `disable-auto-merge-on-maintainer-push` - # job below is what disables auto-merge when a maintainer pushes to a - # bump branch. Skipping `synchronize` here is what keeps that disable - # sticky. + # App-authored bump PRs from this repo, on opened/reopened only. The + # head/base pairing prevents a develop bump landing on main. if: >- (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login == 'ptr727-codegen[bot]' && @@ -167,15 +102,8 @@ jobs: disable-auto-merge-on-maintainer-push: name: Disable auto-merge on maintainer push job runs-on: ubuntu-latest - # Fires on `synchronize` events against bot-authored PRs (Dependabot - # or the ESPHome version tracker) when the event actor is NOT the same bot - i.e. a - # maintainer pushed commits to the bot's branch. Disables auto-merge - # so the maintainer's commits don't auto-merge along with the bot's - # content. The maintainer re-enables auto-merge manually when ready - # (UI button, or `gh pr merge --auto `). - # - # `gh pr merge --disable-auto` is idempotent - calling it on a PR - # that already has auto-merge disabled is a no-op. + # A maintainer pushed to a bot PR (synchronize, actor != the bot): disable + # auto-merge so their commits don't ride along. Idempotent. if: >- github.event.action == 'synchronize' && github.event.pull_request.head.repo.full_name == github.repository && @@ -190,11 +118,8 @@ jobs: steps: - name: Generate GitHub App token step - # App token rather than GITHUB_TOKEN: on a Dependabot PR the - # workflow context runs with Dependabot's restricted secrets - # regardless of who triggered the event (GitHub gates by PR - # origin, not by event actor), and the restricted GITHUB_TOKEN - # is read-only. Same App token pattern as the other merge jobs. + # App token: on a Dependabot PR the default token is read-only + # regardless of who triggered the event. id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: diff --git a/.github/workflows/publish-docker-readme-task.yml b/.github/workflows/publish-docker-readme-task.yml index eb8d83c..31324b1 100644 --- a/.github/workflows/publish-docker-readme-task.yml +++ b/.github/workflows/publish-docker-readme-task.yml @@ -1,15 +1,11 @@ name: Publish docker hub readme task -# Pushes the static Docker Hub repository overview from Docker/README.md. -# Called from publish-release.yml after a real publish (never on smoke); the -# README tracks main so the published overview matches the :latest image. +# Pushes Docker/README.md as the Docker Hub repository overview. on: workflow_call: inputs: - # Branch whose Docker/README.md is published. The publisher passes main so - # the readme matches the main release image even when dispatched from - # another ref. Empty falls back to the caller's default checkout ref. + # Branch whose Docker/README.md is published; empty uses the caller's ref. ref: required: false type: string diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index c989ea9..3ac7028 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -5,37 +5,22 @@ on: branches: [ main, develop ] workflow_dispatch: schedule: - # Weekly full build/publish of both branches on Mondays at 02:00 UTC. - # This is the guaranteed publisher in the default two-phase model: routine - # merges only smoke-test, and this scheduled run republishes everything - # (also refreshing the Docker base image, e.g. `python:3.13-slim`). + # Weekly full publish of both branches; merges otherwise only smoke-test. - cron: '0 2 * * MON' -# Real publishes (schedule, dispatch, or push when PUBLISH_ON_MERGE is set) -# share a single GLOBAL, ref-independent group so they serialize: they push -# both branches' shared Docker tags + caches and create GitHub releases for -# both regardless of the triggering ref, so a ref-scoped group would let a -# scheduled run (ref=main) and a manual dispatch (ref=develop) run concurrently -# and double-push. Non-publishing `push` runs (the two-phase default) get a -# unique per-run group so they don't queue behind - or delay - a real publish; -# they only execute the no-op `setup` job and skip everything else. +# Real publishes share one global group so they serialize (they push shared +# tags/caches regardless of ref); non-publishing pushes get a unique group and +# only run the no-op `setup` job. concurrency: group: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || vars.PUBLISH_ON_MERGE == 'true') && github.workflow || format('{0}-noop-{1}', github.workflow, github.run_id) }} - # Documented exception to the standard `cancel-in-progress: true` (see - # AGENTS.md "Workflow YAML Conventions"): cancelling a publish mid-flight can - # leave a partially pushed multi-arch tag set or a half-created GitHub - # release. Queue instead of cancel so each publish runs to completion. + # Queue, don't cancel: a cancelled publish can leave a half-pushed tag set or + # release. cancel-in-progress: false jobs: - # Decide WHICH branches to publish and WHETHER to publish at all: - # - push -> publish only the pushed branch, and only when the - # `PUBLISH_ON_MERGE` repository variable is `true` - # (opt-in legacy continuous-release). Unset/false => the - # default two-phase model: merges don't publish. - # - schedule -> always publish BOTH branches (the weekly full build). - # - dispatch -> always publish BOTH branches (manual on-demand publish). + # Decide which branches to publish and whether to publish: push publishes only + # when PUBLISH_ON_MERGE is true; schedule/dispatch publish both branches. setup: name: Resolve publish plan job runs-on: ubuntu-latest @@ -46,8 +31,7 @@ jobs: - name: Compute publish plan step id: plan env: - # Repository variable (Settings -> Actions -> Variables). Unset reads - # as empty string, so the default is the two-phase model. + # Unset reads as empty string -> two-phase default. PUBLISH_ON_MERGE: ${{ vars.PUBLISH_ON_MERGE }} run: | set -euo pipefail @@ -70,10 +54,8 @@ jobs: echo "branches=$branches" >> "$GITHUB_OUTPUT" echo "publish=$publish" >> "$GITHUB_OUTPUT" - # Full build + publish of every target for each planned branch. The branch - # matrix lets a single scheduled run publish both `main` (Release/`latest`, - # non-prerelease) and `develop` (Debug/`develop`, prerelease) - each leg - # checks out and versions its own branch via the threaded `ref`/`branch`. + # Full publish per planned branch; the matrix lets one run publish both main + # and develop. publish: name: Publish project release job needs: [setup] @@ -90,15 +72,10 @@ jobs: ref: ${{ matrix.branch }} branch: ${{ matrix.branch }} smoke: false - # Publish the GitHub release and push the Docker image. Docker is the only - # delivery target this repo ships. github: true dockerhub: true - # Push the static Docker Hub repository overview after a real publish, wired - # like `date-badge` below; the README tracks `main` so the published overview - # matches the `:latest` image. Runs only on real publishes (gated on - # `setup.publish`), never on smoke. + # Push the Docker Hub README (from main) after a real publish. docker-readme: name: Publish docker hub readme job needs: [setup, publish] @@ -120,5 +97,5 @@ jobs: permissions: contents: write with: - # The badge task self-gates to `main`; the develop leg is a no-op. + # The badge self-gates to main. branch: ${{ matrix.branch }} diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 90f94d3..b042dbe 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -11,21 +11,13 @@ concurrency: jobs: - # Detect whether a PR touches the Docker build so we only smoke-build when it - # changed. Build-workflow files are intentionally NOT in the filter: a path - # filter can't tell a logic change in a build workflow from an action-version - # bump. A workflow-only change is therefore not smoke-built - the reusable - # workflows are exercised instead by the next run that uses them (a later code - # PR's smoke build, or the scheduled/publish run); lint workflow edits with - # `actionlint` locally before pushing (there is no CI lint job). On - # `workflow_dispatch` (no PR base to diff against) the target is forced on so - # a manual run is a full smoke build. + # Smoke-build only when the Docker context changes. Workflow files aren't + # filtered, so a workflow-only change isn't smoke-built (lint locally with + # actionlint). workflow_dispatch forces a full smoke build. changes: name: Detect changed targets job runs-on: ubuntu-latest - # `dorny/paths-filter` lists the PR's changed files via the GitHub API - # (this job does not check out the tree), which needs `pull-requests: read`. - # The repo's default GITHUB_TOKEN is restricted, so grant it explicitly. + # paths-filter reads the changed files via the API; needs pull-requests: read. permissions: contents: read pull-requests: read @@ -40,16 +32,12 @@ jobs: filters: | docker: - 'Docker/**' - # The pinned ESPHome version (build-arg + tag) lives here, and the - # NBGV release version comes from version.json - a change to either - # should rebuild the image. + # Pinned ESPHome version and NBGV version source. - 'esphome-version.json' - 'version.json' - # Fast PR feedback: build the Docker image in smoke mode (amd64-only, no - # publishing). Validates against the PR's base branch by passing - # `branch: github.base_ref`. Skipped entirely when the Docker context did not - # change (e.g. a docs-only PR). + # Build the image in smoke mode (amd64, no push) against the PR base branch; + # skipped when the Docker context didn't change. smoke-build: name: Smoke build changed targets job needs: [changes] @@ -58,23 +46,17 @@ jobs: secrets: inherit with: smoke: true - # Do not publish anything from a PR. github: false dockerhub: false - # Check out the PR head by SHA (not head_ref): the head SHA is reachable - # in the base repo via refs/pull/N/head even for fork PRs, whereas the - # head_ref branch name does not exist in the base repo for forks and - # would fail checkout. Validate it in the base branch's configuration. - # `workflow_dispatch` has no pull_request payload, so fall back to the - # triggering ref. + # PR head by SHA so fork PRs check out; dispatch falls back to the ref. ref: ${{ github.event.pull_request.head.sha || github.ref_name }} branch: ${{ github.base_ref || github.ref_name }} enable_docker: ${{ needs.changes.outputs.docker == 'true' }} - # TODO: Workaround for GitHub Actions not supporting status checks on conditional jobs + # Aggregator giving the PR one required status check across the conditional + # jobs. Its name is the required-check context in the branch ruleset - do NOT + # rename it. # https://github.com/orgs/community/discussions/12395#discussioncomment-12970019 - # This job's name is bound to the branch ruleset as the required status check - # context - do NOT rename it (see AGENTS.md "Workflow YAML Conventions"). check-workflow-status: name: Check pull request workflow status runs-on: ubuntu-latest @@ -91,14 +73,11 @@ jobs: exit 1 fi } - # The paths-filter job MUST succeed: if it failed we don't know whether - # the Docker target changed, so a Docker-changing PR could merge with - # its smoke build silently skipped. Treat anything other than success - # as a block. + # `changes` must succeed, else we don't know if Docker changed and a + # smoke build could be silently skipped. if [[ "${{ needs.changes.result }}" != "success" ]]; then echo "Job 'changes' did not succeed (${{ needs.changes.result }}); refusing to pass." exit 1 fi - # smoke-build may be legitimately skipped (Docker context unchanged) - - # `skipped` passes, only failure/cancelled blocks. + # smoke-build skipped (unchanged) passes; only failure/cancelled blocks. exit_on_result "smoke-build" "${{ needs.smoke-build.result }}"