diff --git a/reports/esphome-nonroot/audit.md b/reports/esphome-nonroot/audit.md new file mode 100644 index 00000000..3e029bbe --- /dev/null +++ b/reports/esphome-nonroot/audit.md @@ -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-` (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 `:` 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 ``/``/`` 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 `:` 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--*` 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-`, `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 `:`, 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 ``/``/`` 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.