Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions reports/esphome-nonroot/audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Audit: ESPHome-NonRoot

- **Audited branch:** main (`b44b1a6ca3741ad42140e060d66a88c2d6e75a0f`)
- **Types:** docker, upstream-wrapper (from registry)
- **Verdict:** operational
- **Date:** 2026-07-04

## Develop Drift

`develop` vs `main`: ahead 1, behind 44 (`gh api repos/ptr727/ESPHome-NonRoot/compare/main...develop` -> `status: diverged, ahead_by: 1, behind_by: 44`). **Diverged - a drift finding.** `develop` carries 1 commit `main` lacks and is 44 behind. Under the forward-only model (no `main -> develop` back-merge) `main`-only changes (the audited CI/CD rework, Dependabot and upstream-pin bumps merged straight to `main`) have not reached `develop`. The publisher's push trigger is `main`-only (`publish-release.yml:19-21`), so develop's daily pin bump is sync-only anyway; still worth reconciling.

## Dimensions

| Dimension | Letter | Intent | Verdict | Evidence (file:line) |
| --- | --- | --- | --- | --- |
| csharp | - | - | N/A | no `*.csproj`/`*.slnx`; Docker-only repo. `.editorconfig:55-222` retains the shared `[*.cs]`+ReSharper block as inert governance |
| nuget | - | - | N/A | no `dotnet nuget push` |
| pypi | - | - | N/A | ESPHome/device-builder are resolved from PyPI as upstream pins, not published |
| python | - | - | N/A | no Python project in-repo |
| console | - | - | N/A | no `build-executable-task.yml` |
| docker | pass | pass | pass | registry layer cache `buildcache-<branch>` (never `type=gha`) `build-docker-task.yml:104-107` (`cache-from` both branches, `cache-to` only-branch + only-on-push); trimmed `Docker/README.md` published via `peter-evans/dockerhub-description`, main-only `:114-121`; image always re-pushes on `inputs.push` (`dockerhub && !smoke`), independent of the release-create skip `build-release-task.yml:63,125`. Static `DOCKER_HUB_*` secret (expected for docker, not OIDC) |
| upstream-wrapper | pass | pass | pass | the leaf **consumes** `upstream-version.json`: `jq -r .esphome`/`.device_builder` `build-docker-task.yml:55,60`, driving the pinned `:<esphome-version>` image tag `:99` and `ESPHOME_VERSION`/`DEVICE_BUILDER_VERSION` build-args `:110-111` (genuinely pinned, not tagged off NBGV - better than the template's tracker-without-consumer default); tracker `bump-branch-prefix: upstream-version` + `branches: ["main","develop"]` (`check-upstream-version-task.yml:26,33`) match the merge-bot's `upstream-version-{main,develop}` pairs `merge-bot-pull-request.yml:71-72` |
| branch-model | drift | pass | drift | both branches protected (live rulesets `enforcement: active`); live match committed `repo-config/ruleset-{develop,main}.json` **except** the committed files omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []`. Required-check `context` `Check pull request workflow status job` matches both committed and the repo's aggregator `name:` `test-pull-request.yml:48` - new fleet canonical, not drift. Off-baseline filenames - see Drift |
| repo-setup | pass | pass | pass | actions + dependabot stores both hold `DOCKER_HUB_USERNAME`, `DOCKER_HUB_ACCESS_TOKEN` (docker-hub) and `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (merge-bot `merge-bot-pull-request.yml:42-43` + upstream tracker `check-upstream-version-task.yml:55-56`); no forbidden `NUGET_API_KEY`/`CODEGEN_APP_ID`. Stale unused `DOCKER_HUB_PASSWORD` - see Drift |
| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:30-45`); CI runs markdownlint `:31-33`, cspell `:36-42`, actionlint `:44-45`; no C#/Python configs needed (Docker-only) |
| recurring-violations | drift | pass | drift | comments concise; docs ASCII-clean (em-dash/smart-quote grep -> none); `cspell.json:3` sets `"language": "en-US"`. **Letter miss (eol):** `.editorconfig` lacks the global `[*] end_of_line = crlf` default **and** 5 workflow YAML are committed LF against its own `[*.{yml,yaml}] crlf` rule - see Drift #1/#2 |
| readme-structure | drift | pass | drift | distribution bullets, status shields, Release Notes, Usage/Configuration/Compose, Use Case, License present and content-complete, but reshaped: no `## Table of Contents`, no `## Questions or Issues`, `## Status`/`## Release Notes` are top-level `##` siblings rather than `###` under `## Build and Distribution`, `## Use Case` `README.md:163` sits **after** `## Usage` `:32`, and the reference-link block `:210-255` has no `<!-- Shields -->`/`<!-- Repo -->`/`<!-- External -->` dividers. `Docker/README.md` is the intentionally-trimmed Hub overview, not measured against the project-README spec |
| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): single-branch-per-run publisher, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, dispatch guard skips instead of failing fast, tag-only release without `expect_release_assets: false` |

## WORKFLOW.md 5A Static Audit (applicable D-guarantees)

- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job; `validate` and `smoke-build` run on **every** push (`test-pull-request.yml:11-42`, deliberate per header `:3-6`). Over-builds; the "changed target slips unbuilt" failure cannot occur with a single always-built target.
- **D1.2 (validation always runs):** PASS - `validate` (the lint gate, this Docker-only repo's replacement for `unit-test`) runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator and `smoke-build` both reuse/`needs:` it.
- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `github: false`, `dockerhub: false`, `smoke: true` (`test-pull-request.yml:38-42`); the Docker push is gated `inputs.dockerhub && !inputs.smoke` (`build-release-task.yml:63`), github-release `inputs.github && !inputs.smoke` `:68`. No `upload-artifact` exists (Docker pushes directly).
- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:47-63`). Name is the ruleset-bound context `:48`.
- **D2.1 / D2.2 (validate-at-entry):** DRIFT (letter) / PASS (intent) - the release gate is the `Verify public release version step` **inside** github-release (`build-release-task.yml:82-92`), not an upfront entry job; it checks **one direction only** (main-not-prerelease) and is `!smoke`-guarded. Safe because `version.json:4-6` makes `main` the sole public ref.
- **D2.3 (dispatch guard):** DRIFT - the publish job `if: github.ref_name == 'main' || github.ref_name == 'develop'` (`publish-release.yml:38`) **silently skips** a dispatch from any other ref rather than failing fast with `::error::`.
- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - NBGV runs once in `get-version` on `inputs.ref` (`get-version-task.yml:47-56`); **no** `IGNORE_GITHUB_REF=true`. Correct because the publisher builds one branch per run with `github.ref` aligned; a future both-branch matrix would misclassify.
- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; gate literal `inputs.branch == 'main'` `build-release-task.yml:83`; `prerelease: inputs.branch != 'main'` `:131`; image tags `main => latest`, else `develop` `build-docker-task.yml:98`.
- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.8"`; NBGV appends the git height; retained though the repo has no compiled code (owns the release tag/`GitCommitId`).
- **D3.4 (registry version per classification):** PASS - every image carries the `:SemVer2` tag (`build-docker-task.yml:100`) plus the moving `latest`/`develop` and the pinned `:<esphome-version>` tag `:98-99`.
- **D3.5 (wrapper external version):** PASS - the build reads the committed `name -> version` state file `upstream-version.json` (`esphome 2026.6.4`, `device_builder 1.0.28`) for the immutable image tag and build-args (`build-docker-task.yml:55,60,99,110-111`), while NBGV still tags the release. This is the consumer wiring the template ships the tracker but not.
- **D4.1 (two-phase / both branches):** DRIFT - the publisher triggers on `schedule` (weekly, `main` only), `workflow_dispatch`, and a `main`-only `push` filtered to `upstream-version.json` (`publish-release.yml:14-21`); ordinary code merges never publish. But it builds **only** the trigger branch `:44-45`, not both via a matrix; `develop` publishes only via manual dispatch. Outcomes correct; structural divergence.
- **D4.2 (tag the built commit):** PASS - `target_commitish: needs.get-version.outputs.GitCommitId` `build-release-task.yml:130`; github-release checks out the same commit `:78`.
- **D4.3 (release contents):** DRIFT (letter) / PASS (intent) - tag + `generate_release_notes` + `LICENSE` + `README.md` `build-release-task.yml:128-135`; `prerelease = branch != main`. This Docker-only repo has **no** `expect_release_assets: false` input - the bespoke release task instead omits `fail_on_unmatched_files` and lets `pattern: release-asset-<branch>-*` match zero files (`:96-101,123`). Equivalent tag-only outcome; forks the template's mechanism.
- **D4.4 (no-op republish):** PASS - release-create gated `exists == false || workflow_dispatch` `build-release-task.yml:104-125`; Docker still re-pushes within the same run.
- **D5 (cleanup):** N/A - no `upload-artifact` / cross-job transfer artifact (Docker pushes to the registry directly; the release attaches repo files, not a produced asset).
- **D6.1 / D6.2 / D6.3 (seam):** PASS (mostly N/A) - the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`) `build-release-task.yml:99-100`; branch-derived config/tags key off `inputs.branch` (`build-docker-task.yml:98-99`, `build-release-task.yml:83,131`), never `github.ref_name`; the single Docker target contributes no `release-asset-*` (registry push).
- **D7.1 / D7.2 (concurrency / permissions):** PASS - publisher group `${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:25-27`); the publish caller grants `contents: write` at the one entry point `:41-42`; the tracker and merge-bot jobs declare least-privilege `permissions:`; Docker uses the static secret (no OIDC).
- **D7.4 (optional-dependency chaining):** PASS - `build-docker` uses `!cancelled() && get-version == 'success' && (validate == 'success' || 'skipped')` `build-release-task.yml:53`.
- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened`, method by base ref (`develop => --squash`, `main => --merge`) `merge-bot-pull-request.yml:48-56`; a separate `merge-upstream-version` job pins the bump-PR pairing `:66-73`; disable on maintainer `synchronize` `:108-115`; concurrency keyed on the PR number `:18`.
- **D8.3 (upstream-version tracker):** PASS - a daily scheduled resolver (`check-upstream-version.yml:9-12,35-41`) prints a `name -> version` JSON object (both PyPI components), writes CRLF-normalized `upstream-version.json` (`check-upstream-version-task.yml:88-90`), opens a rolling App-signed per-branch bump PR naming only moved keys `:94-133`, the merge-bot auto-merges it, and the build reads the pin on the next publish.
- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:52-56`).
- **D9.4 (docker cache):** PASS - registry `buildcache-<branch>`, `cache-to` writes only the built branch and only on push, `cache-from` reads both (`build-docker-task.yml:104-107`).
- **D9.5 (line endings):** DRIFT - see Drift #1 (5 workflow YAML committed LF against `.editorconfig`).
- **NuGet / PyPI / console 5A addenda:** N/A.

## WORKFLOW.md 5B Trace (applicable scenarios)

- **S1** (PR touches the Docker target): `validate` (lint) + `smoke-build` (amd64-only, no push) run; no push/upload; github-release skipped; aggregator success; version prerelease. PASS.
- **S2 / S3** (docs-only / workflow-only PR): `smoke-build` **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT.
- **S4** (PR base = main): smoke prerelease; the main-only verify step is inside the `!smoke` github-release so it does not fire; promotion not blocked. PASS.
- **S5** (push not the pin file / non-main): the publisher push trigger is `main`-only and path-scoped to `upstream-version.json` (`publish-release.yml:19-21`), so no push publishes. PASS (stronger than opt-in).
- **S6** (push to develop): N/A - no develop push-publish path; develop's daily pin bump merges but publishes only via manual dispatch.
- **S7** (scheduled/dispatched publish): builds **only** the trigger branch. Schedule -> `main` -> stable + `latest` + pinned `:<esphome-version>`, Docker Hub overview refreshed; dispatch from `develop` -> prerelease + `develop` tag. DRIFT (single-branch); outcomes correct.
- **S8** (dispatch from a non-publishable ref): publish job **skipped** (template fails fast). DRIFT.
- **S9** (re-run, version unchanged): release-create skipped (tag exists, non-dispatch); Docker still re-pushes; no duplicate release. PASS.
- **S10** (branch/version disagree): the main-only verify step fails loud if `main` carries a prerelease suffix (`build-release-task.yml:88-91`). PASS.
- **S11** (wrapper bump): daily tracker detects a PyPI move -> commits `upstream-version.json` -> opens `upstream-version-{main,develop}` PRs -> merge-bot auto-merges each -> the `main` merge's path-scoped push publishes immediately with the bumped pin; the `develop` bump ships only on the next dispatch. PASS (with the develop-publish caveat).

## Defects (most severe first)

None. No applicable check fails both letter and intent.

## Drift Findings

1. **Five workflow YAML files are committed with LF, violating the repo's own `.editorconfig` CRLF rule.** `build-docker-task.yml`, `build-release-task.yml`, `publish-release.yml`, `test-pull-request.yml`, and `validate-task.yml` are `i/lf` (`git ls-files --eol`), while `.editorconfig:35-36` mandates `[*.{yml,yaml}] end_of_line = crlf` and the other four workflow YAML are CRLF. Nothing caught the split because `.gitattributes:6` uses `* -text` (no normalization) and `.editorconfig` carries no global `[*] end_of_line = crlf` default (`:17-22`). A **concrete** `recurring.eol` violation - stronger than the abstract missing-default the other three repos showed. No runtime impact (Actions parses either), but a genuine hygiene miss and diff-churn risk.
2. **`.editorconfig` lacks the canonical global line-ending default.** The `[*]` block (`.editorconfig:17-22`) sets charset/indent but **no** `end_of_line`; CRLF is re-declared per file-type (`:26,31,36,41,53,60`) with LF pins for `*.sh`/Dockerfiles (`:44-49`) - the older per-extension form the strengthened `recurring.eol` flags. Template canonical is `[*] end_of_line = crlf` + LF pins. **(Recurs with Utilities / PlexCleaner / LanguageTags - now four repos, and here it enabled finding #1.)**
3. **Publisher builds one branch per run, not a both-branch matrix.** `publish-release.yml:14-21,44-45` publishes only the trigger branch; the weekly schedule and pin-push rebuild `main` only, `develop` only on manual dispatch. WORKFLOW.md D4.1's model schedules both via a matrix. Outcomes correct; `develop` gets no scheduled refresh. **(Recurs with PlexCleaner.)**
4. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:11-42` runs `validate` + `smoke-build` on every push. **(Recurs across the fleet.)**
5. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []`. Enforcement intact (context matches). **(Recurs with LanguageTags.)**
6. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json` expects `repo-config/develop.json` / `main.json`. **(Recurs with Utilities / PlexCleaner / LanguageTags.)**
7. **NBGV without `IGNORE_GITHUB_REF`; release gate one-directional and embedded.** `get-version-task.yml:47-56` runs NBGV with no `IGNORE_GITHUB_REF=true`; `build-release-task.yml:82-92` verifies only that `main` is not prerelease, inside github-release. Correct only because one branch is built per run. **(Recurs with PlexCleaner / LanguageTags.)**
8. **Dispatch guard skips instead of failing fast.** `publish-release.yml:38` silently no-ops a dispatch from a non-`main`/`develop` ref; WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Recurs across the fleet.)**
9. **Docker-only tag-only release without `expect_release_assets: false`.** The bespoke `build-release-task.yml` omits `fail_on_unmatched_files` and lets the asset glob match zero files (`:96-101,123`), attaching `LICENSE`/`README.md` directly. Equivalent tag-only outcome; forks the template's `expect_release_assets` mechanism.
10. **README reshaped from the canonical structure.** No `## Table of Contents`, no `## Questions or Issues`; `## Status`/`## Release Notes` are top-level `##` rather than `###` under `## Build and Distribution`; `## Use Case` `README.md:163` sits after `## Usage` `:32`; the reference-link block `:210-255` has no `<!-- Shields -->`/`<!-- Repo -->`/`<!-- External -->` dividers. Content complete; shape non-canonical.
11. **Stale `DOCKER_HUB_PASSWORD` secret.** Present in both stores but referenced by no workflow (all Docker steps use `DOCKER_HUB_ACCESS_TOKEN`). Not forbidden; a stale secret to prune. **(Mirrors PlexCleaner's unused `CODECOV_TOKEN`.)**
12. **`develop` diverged from `main`** (ahead 1, behind 44). See Develop Drift.

## Proposed Registry / Spec Updates

- Registry `ESPHome-NonRoot` types `["docker","upstream-wrapper"]`, publish `docker` via `static-secret`, status `cataloged` are accurate; no change. Consider advancing `status` once the structural workflow drifts and the EOL hygiene are addressed.
- **Spec signal (strongest EOL case yet).** The missing global `[*] end_of_line = crlf` default now recurs across four repos, and here it produced **actual** non-compliant endings (five workflow YAML committed LF) because `.gitattributes: * -text` does not renormalize - the abstract miss became concrete. Strengthens the case for a machine check that asserts (a) a global `[*] end_of_line` default exists and (b) tracked files' committed endings match `.editorconfig`.
- **Spec gap - merge-bot / upstream-tracker App secrets.** As with PlexCleaner, `spec/secrets.json` models `CODEGEN_APP_CLIENT_ID`/`CODEGEN_APP_PRIVATE_KEY` only under `codegen-app`, but here they are required by the **merge-bot** and the **upstream-version tracker** on a repo with no codegen. Model a `merge-bot`/`app-signing` mechanism requiring the App secrets so repo-setup does not read them as orphaned.
- **Spec gap - wrapper tag-only release without `expect_release_assets`.** The tag-only release here is reached by omitting `fail_on_unmatched_files` in a bespoke task rather than the template's `expect_release_assets: false`. If wrapper repos consistently hand-roll the release task, consider whether the spec should bless the omit-glob equivalent or require the flag.
- **Recurring cross-fleet drifts** (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, skip-not-fail dispatch guard, single-branch publisher, NBGV without `IGNORE_GITHUB_REF`) appear here too.