From 5136b5640b5f987427ad179ed4ff32bd7ccecefe Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 13 Jul 2026 08:28:22 -0700 Subject: [PATCH 1/6] Add operational workflow model for live-config repos (#287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Introduces a second, first-class branching/workflow model - **operational** - alongside the default **release** model, selected per repo by a new registry `workflowModel` field (`release` | `operational`, default `release`). **Operational** repos (live-service config: Vantage-Config, ESPHome-Config, HomeAutomation, HomeAssistant-Config) commit configuration **directly to `develop`** (no feature branch) and promote a known-good snapshot to `main` via an occasional PR. - Only the `develop` ruleset differs: new `repo-config/operational/develop.json` allows direct signed pushes (`deletion` + `non_fast_forward` + `required_signatures` only). `main.json` is **shared**, so the promotion PR still enforces the required lint check - a broken config can never reach `main`. - **`develop`** = live edits with *advisory* CI on push; **`main`** = known-good snapshot with CI *enforced* on the promotion. - CI is **lint/validation only** (editorconfig/EOL plus domain linters - HA/ESPHome config validation, firmware build - **no unit tests**). - Still cut GitHub releases, but **only** by manual `workflow_dispatch` (`releaseTrigger: dispatch-only`), never automatically. ## Changes - **registry**: `workflowModel` enum + property + default; four operational repos marked (`workflowModel`, `groundTruthBranch`, `dispatch-only` release, `github-release` publish); `validate.py` enforces the enum. - **repo-config**: `operational/develop.json` variant; `configure.sh` selects the `develop` ruleset from the repo's `workflowModel` (override via 2nd arg). - **spec/project-types.json**: branch-model audit is model-aware; adds the operational lint-CI expectation. - **docs**: AGENTS.md, WORKFLOW.md, repo-config/README.md, README.md document both models; cspell allows `esphome`/`hass`. ## Verification `python3 spec/validate.py` passes (21 cataloged clean; rejects an invalid `workflowModel`); `bash -n configure.sh` clean; `configure.sh` model resolution verified for all repos; markdownlint + cspell + editorconfig-checker green. ## Follow-up (separate, per-repo rollout) Bringing the four repos up to the operational baseline (missing files, lint+domain CI, `version.json` + `publish-release.yml`, apply rulesets) is a separate live-repo effort, starting with ESPHome-Config end-to-end. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 7 ++-- README.md | 2 +- WORKFLOW.md | 15 ++++++++ cspell.json | 2 ++ registry/repos.json | 37 ++++++++++--------- registry/repos.schema.json | 5 ++- repo-config/README.md | 12 +++++-- repo-config/configure.sh | 54 +++++++++++++++++++++------- repo-config/operational/develop.json | 31 ++++++++++++++++ spec/project-types.json | 5 +-- spec/validate.py | 10 ++++++ 11 files changed, 142 insertions(+), 38 deletions(-) create mode 100644 repo-config/operational/develop.json diff --git a/AGENTS.md b/AGENTS.md index 18ec6e99..448a65c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Treat this file as authoritative for everything else; don't restate its rules el The specific rules in this file implement a few governing principles. Read these first: they are the reason the branching, release, and versioning rules are shaped the way they are, and every rule below serves one of them. - **Distribution respects the user: pull by default, push only where the channel forces it.** Docker images, GitHub Releases, and NuGet/PyPI packages are **pull** - the user decides when to consume them. A few channels are **push**: HACS surfaces a new release to every installed user as a pending update they did not go looking for, and a consumer that vendors from `main` picks up its current state. Because a release can reach users who did not ask for it, releasing is a deliberate act that marks a real functional change - never mechanical churn. This is why a **human merge never auto-publishes** - a release is a deliberate `workflow_dispatch`, or a conditional auto-release when the App merges a code-affecting Dependabot/codegen PR to `main` (Docker also refreshes on a weekly schedule) - together with the no-op republish guarantee and maintainer-gated version bumps: a needless release spends the user's attention and, on a push channel, acts on their machine. -- **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges** - forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only; never back-merge. See "Branching Model". +- **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges** - forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only; never back-merge. See "Branching Model". (This dual-target sync and the auto-publish rules below describe `release` repos; **operational** repos - registry `workflowModel: operational` - run no bots and commit directly to `develop`. They still cut a GitHub release, but **only** by manual `workflow_dispatch` (`releaseTrigger: dispatch-only`) - never automatically. See "Branching Model".) - **Two version numbers, two jobs.** The 2-digit `major.minor` in `version.json` carries human meaning - the maintainer raises it only for a functional change (feature, behavior or API change, breaking change), at their discretion - while NBGV owns the patch position and always increments with git height, so every build is uniquely versioned with no edit. Human-facing docs name the 2-digit line; the toolchain guarantees monotonic builds. See "Release Model". - **Contracts state what, not how, and favor reuse.** [`WORKFLOW.md`](./WORKFLOW.md) fixes required outcomes, not a required implementation - two repos may satisfy a guarantee with different YAML. Within that freedom, apply good engineering practice: minimize duplication and maximize reuse, which is why the pipeline splits a carried, generic orchestration layer from a repo-owned build layer. @@ -25,6 +25,7 @@ The specific rules in this file implement a few governing principles. Read these ## Branching Model +- **Two workflow models, set per repo by the registry [`workflowModel`](./registry/repos.json) field.** Most repos are `release`: they ship versioned units of delivery through the feature -> `develop` -> `main` flow this section describes. **Operational** repos (live-service config - Home Assistant, ESPHome, Vantage, home automation) instead track the running state of a service: the maintainer commits configuration **directly to `develop`** (there is no feature branch) and *occasionally* opens a `develop -> main` PR to bless a known-good snapshot. For an operational repo the [`develop` ruleset](./repo-config/operational/develop.json) drops the PR and status-check gate - direct signed pushes are allowed (force-push, deletion, and unsigned commits are still blocked) and CI runs on the push as **advisory** feedback that never rejects a commit. The [`main` ruleset](./repo-config/main.json) is shared and **unchanged**, so the promotion PR still **enforces** the required `Check pull request workflow status job`; that check is lint/validation only (editorconfig/EOL plus domain linters - a Home Assistant or ESPHome config validation, a firmware build - never unit tests), so `develop` stays the live surface and a broken config can never reach `main`. Operational repos still cut GitHub releases (tag + source zip), but **only** by manual `workflow_dispatch` (`releaseTrigger: dispatch-only`), never automatically - see "Release Model". The rest of this section is the `release` model unless noted; the promotion mechanics (never delete `develop`, EOL-conflict resolution) apply to both. - `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 (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. @@ -36,7 +37,7 @@ The specific rules in this file implement a few governing principles. Read these - *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 job` 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 [`README.md`](./repo-config/README.md) "Rules / Rulesets" for the configured state. -- **Configuring branch protection on a fleet repo: 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 template 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 procedure: [README "Rules / Rulesets"](./repo-config/README.md). **Brownfield repos** (pre-existing history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the ruleset - see the [brownfield migration procedure](./repo-config/README.md) in that section. +- **Configuring branch protection on a fleet repo: 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 template 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. Operational repos import [`operational/develop.json`](./repo-config/operational/develop.json) as their `develop` ruleset (the `main` ruleset is shared); [`configure.sh`](./repo-config/configure.sh) selects the right develop payload from the registry `workflowModel` automatically. Full export/import procedure: [README "Rules / Rulesets"](./repo-config/README.md). **Brownfield repos** (pre-existing history) need an extra step: `Require signed commits` rejects legacy unsigned commits and the admin bypass does not cover `git push --force`, so re-signing requires temporarily disabling the ruleset - see the [brownfield migration procedure](./repo-config/README.md) in that section. - **Bots (Dependabot and codegen) target both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch) and [`catalog/snippets/workflows/run-codegen-pull-request-task.yml`](./catalog/snippets/workflows/run-codegen-pull-request-task.yml) runs as a matrix over both branches with branch names `codegen-main` and `codegen-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. The merge-bot auto-merges **every** Dependabot tier including semver-major (no ecosystem or update-type guard): the required CI checks are the gate, not the bump magnitude, so a major that breaks the build fails its checks and never merges. - **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` and `merge-codegen` 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:** push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from `main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code during long-running develop features. Codegen content can also be production-critical (live API-derived data, language lists, build catalogs) rather than just sample/demo content, so both branches need fresh codegen on their own cadence. @@ -46,7 +47,7 @@ The specific rules in this file implement a few governing principles. Read these ## Release Model -The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./WORKFLOW.md) for the full rationale; the load-bearing rules: +The template uses a **two-phase model by default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./WORKFLOW.md) for the full rationale; the load-bearing rules. The auto-publish paths (bot push, schedule) apply to `release` repos; **operational** repos carry `releaseTrigger: dispatch-only`, so they publish **only** on a manual `workflow_dispatch` - the same source-only release the publisher already supports (tag + source zip + README + LICENSE, NBGV-versioned) - never automatically. Their `develop -> main` promotion just blesses a known-good config snapshot; a release is a separate, deliberate dispatch (see "Branching Model"). - **PRs smoke-test only.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) always runs unit tests, then a `dorny/paths-filter` `changes` job gates a **reduced** build of only the changed targets (Docker `linux/amd64` only, executable on a representative runtime subset), never pushing. 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). Workflow YAML is still linted in CI by the lint job's `actionlint` step; also run `actionlint` locally before pushing to catch issues early. - **A human merge never auto-publishes.** [`publish-release.yml`](./.github/workflows/publish-release.yml) is the sole publisher; each run builds the **single trigger branch** (`main` a release, `develop` a prerelease). A first [`plan`](./catalog/snippets/workflows/publish-plan-task.yml) job decides once whether the run publishes and every other job gates on its output. It publishes on a **`workflow_dispatch`** of `main`/`develop` (the human-initiated release), a **code-affecting bot push to `main`** (the codegen App merges every Dependabot/codegen PR, so `github.actor` is the gate - a human merge/promotion to `main` skips), or a **weekly `schedule`** (Docker only, to refresh the base image). The `push` is main-only and paths-filtered, so a develop bot merge and an Actions-only bump publish nothing. A source-only repo publishes on dispatch only. diff --git a/README.md b/README.md index a0f5e726..6db81d9a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ This repo is the single home for the shared rules the fleet follows, a machine-r ProjectTemplate follows the same model it documents, and audits its own rules against itself (it classifies as the source-only project type in [WORKFLOW.md][workflow]). -- **Branching.** Persistent `main` and `develop`, each with its own ruleset. Commit on feature branches only. Feature branch to `develop` is squash-merged; `develop` to `main` is a merge commit. `develop` is forward-only (no `main -> develop` back-merges). See [AGENTS.md "Branching Model"][agents-branching-model]. +- **Branching.** Persistent `main` and `develop`, each with its own ruleset. This repo uses the default `release` workflow model: commit on feature branches only, feature branch to `develop` is squash-merged, `develop` to `main` is a merge commit, and `develop` is forward-only (no `main -> develop` back-merges). Live-service config repos instead use the `operational` model (registry `workflowModel`) - direct signed commits to `develop`, promoted to `main` by an occasional PR. See [AGENTS.md "Branching Model"][agents-branching-model]. - **CI is lint-only.** There is no build or unit test; the PR gate runs markdownlint, cspell, JSON validation (`jq` parses `registry/`, `spec/`, and `repo-config/`, plus the `spec/validate.py` cross-reference and shape checks), and actionlint, and exposes the ruleset-bound `Check pull request workflow status job` aggregator. The same lint configs (`.markdownlint-cli2.jsonc`, `cspell.json`) drive the editor extensions, the CLI, and CI. - **Review loop.** Every PR is reviewed by GitHub Copilot; the agent drives the review loop to green and merges only with explicit maintainer permission. See [AGENTS.md "PR Review Etiquette"][agents-pr-review-etiquette]. - **Release.** A `develop -> main` merge is promoted through a GitHub release (tag plus a source zip, README, and LICENSE); versioning is NBGV-driven from [version.json][version]. See [WORKFLOW.md][workflow]. diff --git a/WORKFLOW.md b/WORKFLOW.md index 9383d2cd..9e6156e3 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -44,6 +44,8 @@ Prescriptive style/legibility rules. Cheap to check, necessary but not sufficien ### Branch Model +Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline this document specifies: + ```mermaid flowchart LR feature[feature branch] -->|squash| develop @@ -51,6 +53,16 @@ flowchart LR main -.->|no back-merge| develop ``` +`operational` repos (live-service config; `workflowModel: operational`) commit directly to `develop` and promote a known-good snapshot to `main` via an occasional PR: + +```mermaid +flowchart LR + edit[direct signed commit] -->|advisory CI| develop + develop -->|merge commit, enforced lint CI| main +``` + +Their CI is lint/validation only (editorconfig/EOL plus domain linters - Home Assistant or ESPHome config validation, a firmware build - **no unit tests**), so the D-guarantees below that assume a build/test pipeline are **N/A** exactly as for `source-only` (Section 6). What binds: the promotion gate - the `develop -> main` PR must pass the required `Check pull request workflow status job` - and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in [AGENTS.md "Branching Model"][agents-branching-model] and [repo-config/README.md][repo-config-readme], not here. + ### Two Layers: Orchestration vs Build - **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. @@ -263,6 +275,7 @@ Each type maps the *applicable* S-scenarios onto its targets; the differences ar - **Docker image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the template ships the tracker but not this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge; non-default pushes the develop tag (amd64 only); S9 still re-pushes; S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. - **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset--library` (`retention-days: 1`, upload gated `!smoke` - mirror the nugetlibrary leaf's shape). Because the template has no such leaf, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5); `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. - **Source-only / no build.** No package/image leaf: remove all four `build-*` jobs and their `github-release` `needs:` entries (leaving `get-version -> validate-release -> github-release`, which fires on `github && !smoke`), and the caller passes `expect_release_assets: false` so the release is tag + source zip + README + LICENSE with no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped** - validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda - recorded N/A, not failed. +- **Operational (workflow model, not a build target).** A `workflowModel: operational` repo layers a direct-commit `develop` onto the **source-only** release shape (above). Two workflows: (1) a **lint/validation** PR workflow feeding the required `Check pull request workflow status job` - the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator (Home Assistant `hass --script check_config`, `esphome config`, a firmware build), **no unit tests**; its triggers differ from the `release` template - `push` to `develop` (advisory feedback on direct commits) plus `pull_request` to `main` (the enforced promotion gate) plus `workflow_dispatch`. (2) the standard **source-only publisher** on `workflow_dispatch` only (`releaseTrigger: dispatch-only`): NBGV + `version.json` own the tag, and a manual dispatch cuts a GitHub release (tag + source zip + README + LICENSE, `expect_release_assets: false`). Applicable scenarios: S1 (validation) on the promotion PR, plus the source-only release set - S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification). N/A: the auto-publish paths (S5/S6 bot-push and schedule - operational repos have neither) and every build/registry scenario. See the branch-model note in Section 3 and [AGENTS.md "Branching Model"][agents-branching-model]. @@ -270,4 +283,6 @@ Each type maps the *applicable* S-scenarios onto its targets; the differences ar +[agents-branching-model]: ./AGENTS.md#branching-model [codestyle]: ./CODESTYLE.md +[repo-config-readme]: ./repo-config/README.md diff --git a/cspell.json b/cspell.json index b53975ac..4d1e71a7 100644 --- a/cspell.json +++ b/cspell.json @@ -46,6 +46,7 @@ "dryrun", "Emby", "envsubst", + "esphome", "extensionless", "fanaticpythoner", "finalizers", @@ -53,6 +54,7 @@ "gpgsign", "gruntfuggly", "HACS", + "hass", "hatchling", "heredocs", "homeassistant", diff --git a/registry/repos.json b/registry/repos.json index 034296fd..ce49b6ab 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -3,7 +3,8 @@ "owner": "ptr727", "defaults": { "groundTruthBranch": "main", - "releaseTrigger": "two-phase" + "releaseTrigger": "two-phase", + "workflowModel": "release" }, "repos": [ { @@ -160,13 +161,14 @@ "url": "https://github.com/ptr727/HomeAutomation", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "main", + "groundTruthBranch": "develop", + "workflowModel": "operational", "hasDevelop": true, - "publish": [], + "publish": [{ "target": "github-release", "mechanism": "none" }], "requiredSecrets": [], "consumerModel": "pull", - "releaseTrigger": "none", - "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs); in scope, file-version-history only.", "Private; README self-flags previously-committed secrets - secrets-hygiene concern."] + "releaseTrigger": "dispatch-only", + "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs).", "Private; README self-flags previously-committed secrets - secrets-hygiene concern.", "Operational rollout pending: lint CI feeding the required check, dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip), and develop-as-ground-truth adoption."] }, { "name": "KiCadLibrary", @@ -199,26 +201,28 @@ "url": "https://github.com/ptr727/ESPHome-Config", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "main", + "groundTruthBranch": "develop", + "workflowModel": "operational", "hasDevelop": true, - "publish": [], + "publish": [{ "target": "github-release", "mechanism": "none" }], "requiredSecrets": [], "consumerModel": "pull", - "releaseTrigger": "none", - "driftNotes": ["ESPHome device config YAML consumed by the cataloged ESPHome-NonRoot image at runtime; distinct from that Docker repo.", "No CI/publish; dependabot covers only the devcontainers ecosystem."] + "releaseTrigger": "dispatch-only", + "driftNotes": ["ESPHome device config YAML consumed by the cataloged ESPHome-NonRoot image at runtime; distinct from that Docker repo.", "Operational rollout pending: an esphome-config validation job feeding the required check, dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip), and develop-as-ground-truth adoption. Dependabot currently covers only the devcontainers ecosystem."] }, { "name": "HomeAssistant-Config", "url": "https://github.com/ptr727/HomeAssistant-Config", "status": "cataloged", "types": ["source-only"], - "groundTruthBranch": "master", + "groundTruthBranch": "main", + "workflowModel": "operational", "hasDevelop": true, - "publish": [], + "publish": [{ "target": "github-release", "mechanism": "none" }], "requiredSecrets": [], "consumerModel": "pull", - "releaseTrigger": "none", - "driftNotes": ["Home Assistant CONFIGURATION (configuration.yaml + automations/blueprints), NOT a HACS integration (no custom_components/manifest.json, no hacs.json).", "Non-conformant: ground-truth branch is 'master', not 'main' - rename to converge.", "Private; deployed by git pull into the HA config dir."] + "releaseTrigger": "dispatch-only", + "driftNotes": ["Home Assistant CONFIGURATION (configuration.yaml + automations/blueprints), NOT a HACS integration (no custom_components/manifest.json, no hacs.json).", "Operational rollout pending: 'master' renamed to 'main' and 'develop' created (done); develop is behind main by 2 commits - fast-forward develop up to main so it holds the live content, then flip groundTruthBranch to develop. Also pending: lint CI (a Home Assistant config-check feeding the required check) and dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip).", "Private; deployed by git pull into the HA config dir."] }, { "name": "DevKitCIoT", @@ -278,12 +282,13 @@ "status": "cataloged", "types": ["source-only"], "groundTruthBranch": "develop", + "workflowModel": "operational", "hasDevelop": true, - "publish": [], + "publish": [{ "target": "github-release", "mechanism": "none" }], "requiredSecrets": [], "consumerModel": "pull", - "releaseTrigger": "none", - "driftNotes": ["Maintainer config/asset archive (Vantage InFusion) with vendored binaries (MSI/7z/PDF) and versioned project snapshots; in scope as a config repo.", "The lone FindInFile C# helper is incidental, not a governed artifact.", "The develop branch now holds the ground truth (README/tagline edits) while main is stale - converge. No CI/governance scaffolding (no .github/workflows)."] + "releaseTrigger": "dispatch-only", + "driftNotes": ["Maintainer config/asset archive (Vantage InFusion) with vendored binaries (MSI/7z/PDF) and versioned project snapshots.", "The lone FindInFile C# helper is incidental, not a governed artifact.", "Operational model: develop holds the ground truth; main is the last promoted snapshot. Rollout pending - no .github/workflows yet: lint CI feeding the required check plus dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip)."] }, { "name": "HolidayLights", diff --git a/registry/repos.schema.json b/registry/repos.schema.json index 2ab1811c..6f4a56a3 100644 --- a/registry/repos.schema.json +++ b/registry/repos.schema.json @@ -13,7 +13,8 @@ "additionalProperties": false, "properties": { "groundTruthBranch": { "type": "string" }, - "releaseTrigger": { "$ref": "#/$defs/releaseTrigger" } + "releaseTrigger": { "$ref": "#/$defs/releaseTrigger" }, + "workflowModel": { "$ref": "#/$defs/workflowModel" } } }, "repos": { @@ -23,6 +24,7 @@ }, "$defs": { "releaseTrigger": { "enum": ["two-phase", "publish-on-merge", "dispatch-only", "none"] }, + "workflowModel": { "enum": ["release", "operational"] }, "mechanism": { "enum": ["oidc", "static-secret", "none"] }, "target": { "enum": ["nuget", "pypi", "docker", "github-release"] }, "repo": { @@ -36,6 +38,7 @@ "types": { "type": "array", "items": { "type": "string" } }, "classificationPending": { "type": "boolean" }, "groundTruthBranch": { "type": "string" }, + "workflowModel": { "$ref": "#/$defs/workflowModel" }, "hasDevelop": { "type": "boolean" }, "publish": { "type": "array", diff --git a/repo-config/README.md b/repo-config/README.md index df3feab2..b1f98fe4 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -3,13 +3,19 @@ Repository and branch configuration held as committed files, kept out of `.github/` (which is reserved for GitHub-Actions-owned content). This mirrors the layout the fleet repos use. - `main.json`, `develop.json` - the branch rulesets as the writable API subset (`name`, `target`, `enforcement`, `bypass_actors`, `conditions`, `rules`). These are the canonical expected payload the audit ([AUDIT.md][audit]) diffs each repo's live rulesets against. -- `configure.sh` - applies the rulesets to a repository via the GitHub API (create or full-payload update, idempotent). Run `repo-config/configure.sh [owner/repo]`. +- `operational/develop.json` - the `develop` ruleset for **operational** repos (registry `workflowModel: operational`): direct signed pushes, no PR gate. `main.json` is shared by both models. See "Rulesets" below. +- `configure.sh` - applies the rulesets to a repository via the GitHub API (create or full-payload update, idempotent). Run `repo-config/configure.sh [owner/repo] [release|operational]`; the model defaults to the registry `workflowModel` lookup. ## Rulesets -`main` requires merge-commit merges (no linear-history rule); `develop` requires squash merges with linear history. Both require signed commits, a passing `Check pull request workflow status job`, resolved review threads, and Copilot review, and block force-pushes and deletion. Both intentionally leave "Require branches to be up to date before merging" **off** - see [AGENTS.md "Branching Model"][agents-branching-model]. +Two workflow models share `main.json` but differ on `develop` (registry `workflowModel`, default `release`): -**Configure by importing these JSON files, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`** - the names are load-bearing (`AGENTS.md` and the workflows reference them). First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh` (or `gh api -X POST repos///rulesets --input repo-config/.json` per file). `gh ruleset` is read-only; creation goes through `gh api`. The required check binds by name and only turns green after `test-pull-request.yml` runs once. To edit a ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). +- **`release`** (`develop.json`): `develop` requires squash merges with linear history and a PR - the feature-branch pipeline. +- **`operational`** (`operational/develop.json`): `develop` takes **direct signed pushes** - only `deletion`, `non_fast_forward`, and `required_signatures`; no PR, no status-check, no Copilot-on-push. CI runs on the push as advisory feedback. This is for live-service config repos that edit `develop` directly and promote a known-good snapshot to `main` via an occasional PR (see [AGENTS.md "Branching Model"][agents-branching-model]). + +`main` (both models) requires merge-commit merges (no linear-history rule), signed commits, a passing `Check pull request workflow status job`, resolved review threads, and Copilot review, and blocks force-pushes and deletion - so a `develop -> main` promotion is always gated even when `develop` takes direct commits. Every ruleset intentionally leaves "Require branches to be up to date before merging" **off** - see [AGENTS.md "Branching Model"][agents-branching-model]. + +**Configure by importing these JSON files, never by hand-building the rules** (hand reconstruction has gone wrong on past setups). The result must be **exactly two rulesets named `develop` and `main`** - the names are load-bearing (`AGENTS.md` and the workflows reference them); only the `develop` *content* varies by model. First remove all legacy classic branch-protection rules and any stray rulesets, then run `configure.sh` (which picks the `develop` payload from the repo's `workflowModel`), or `gh api -X POST repos///rulesets --input repo-config/.json` per file (operational repos use `operational/develop.json` for `develop`). `gh ruleset` is read-only; creation goes through `gh api`. The required check binds by name and only turns green after the repo's PR workflow runs once. To edit a ruleset, GET it, change the field, and PUT the whole writable subset back (a partial PUT `422`s). To change the canonical rulesets, edit the live rulesets here, then regenerate the committed files: diff --git a/repo-config/configure.sh b/repo-config/configure.sh index ab460fd6..3e21767c 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -2,17 +2,47 @@ # Apply the committed fleet configuration in this directory to the repository via the GitHub API: # 1. General repository settings from settings.json (PATCH /repos/{owner}/{repo}), plus the two settings that depend on # per-repo state - has_discussions (public repos only) and default_branch (main, only if it exists). -# 2. The branch rulesets develop.json and main.json. Each .json holds the writable ruleset subset -# {name, target, enforcement, bypass_actors, conditions, rules}. An existing ruleset (matched by name) is -# updated with a full-payload PUT (partial PUTs 422); a missing one is created with POST. +# 2. The branch rulesets. main.json is shared by both workflow models; the develop ruleset is model-specific - +# release repos use develop.json (PR-gated), operational repos use operational/develop.json (direct signed +# pushes). The model is read from ../registry/repos.json (per-repo workflowModel, else defaults.workflowModel, +# else release) and can be overridden with the second argument. Each .json holds the writable ruleset +# subset {name, target, enforcement, bypass_actors, conditions, rules}. An existing ruleset (matched by name) +# is updated with a full-payload PUT (partial PUTs 422); a missing one is created with POST. # Rerunning is idempotent. # -# Usage: repo-config/configure.sh [owner/repo] (defaults to the current repo via gh) +# Usage: repo-config/configure.sh [owner/repo] [release|operational] (repo defaults to the current repo via gh; +# model defaults to the registry lookup) set -euo pipefail repo="${1:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# ----- Resolve the workflow model (selects the develop ruleset) ----- +registry="$script_dir/../registry/repos.json" +name="${repo##*/}" +model="${2:-}" +if [ -z "$model" ]; then + if [ -f "$registry" ]; then + # Fail fast on a jq/parse error (malformed registry) instead of silently applying the release default + # to a repo whose lookup actually broke. A repo simply absent from the registry is not an error: the + # expression falls back through defaults.workflowModel to "release", so jq still exits 0 with a value. + if ! model="$(jq -r --arg n "$name" '(.repos[] | select(.name==$n) | .workflowModel) // .defaults.workflowModel // "release"' "$registry")"; then + echo "Failed to read workflowModel from $registry (invalid JSON?). Pass the model explicitly as arg 2." >&2 + exit 1 + fi + else + # No registry to consult (e.g. running the script standalone) - default, but say so. + echo "Registry $registry not found; defaulting workflow model to release." >&2 + model="release" + fi +fi +case "$model" in + release) develop_ruleset="$script_dir/develop.json" ;; + operational) develop_ruleset="$script_dir/operational/develop.json" ;; + *) echo "Unknown workflow model '$model' (expected release or operational)." >&2; exit 1 ;; +esac +echo "Workflow model for $repo: $model" + # ----- General repository settings ----- settings_file="$script_dir/settings.json" if [ -e "$settings_file" ]; then @@ -32,14 +62,14 @@ if [ -e "$settings_file" ]; then fi # ----- Branch rulesets ----- -for file in "$script_dir"/*.json; do +# main.json is shared; the develop ruleset was selected by workflow model above. +for file in "$develop_ruleset" "$script_dir/main.json"; do [ -e "$file" ] || continue - # settings.json is not a ruleset - it has no .name; skip it here (applied above). - name="$(jq -r '.name // empty' "$file")" - [ -n "$name" ] || continue + ruleset_name="$(jq -r '.name // empty' "$file")" + [ -n "$ruleset_name" ] || continue # Paginate so a name match on a later page is never missed (which would create a duplicate ruleset), and # fail loudly if the API call itself fails (auth/404/network) rather than treating it as "not found". - if ! ids="$(gh api --paginate "repos/$repo/rulesets" --jq ".[] | select(.name==\"$name\") | .id")"; then + if ! ids="$(gh api --paginate "repos/$repo/rulesets" --jq ".[] | select(.name==\"$ruleset_name\") | .id")"; then echo "Failed to list rulesets for $repo (check auth and repo access)." >&2 exit 1 fi @@ -49,15 +79,15 @@ for file in "$script_dir"/*.json; do if [ -n "$ids" ]; then count="$(printf '%s\n' "$ids" | grep -c .)" if [ "$count" -gt 1 ]; then - echo "Warning: $count rulesets named '$name' on $repo; updating the first (resolve the duplicates)." >&2 + echo "Warning: $count rulesets named '$ruleset_name' on $repo; updating the first (resolve the duplicates)." >&2 fi id="$(printf '%s\n' "$ids" | sed -n '1p')" fi if [ -n "$id" ]; then - echo "Updating ruleset '$name' (id $id) on $repo" + echo "Updating ruleset '$ruleset_name' (id $id) on $repo" gh api --method PUT "repos/$repo/rulesets/$id" --input "$file" >/dev/null else - echo "Creating ruleset '$name' on $repo" + echo "Creating ruleset '$ruleset_name' on $repo" gh api --method POST "repos/$repo/rulesets" --input "$file" >/dev/null fi done diff --git a/repo-config/operational/develop.json b/repo-config/operational/develop.json new file mode 100644 index 00000000..0d16930e --- /dev/null +++ b/repo-config/operational/develop.json @@ -0,0 +1,31 @@ +{ + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/develop" + ] + } + }, + "enforcement": "active", + "name": "develop", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + } + ], + "target": "branch" +} diff --git a/spec/project-types.json b/spec/project-types.json index d4d3717a..aca27749 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -110,8 +110,9 @@ "appliesTo": "*", "checks": [ { "id": "branch.both-protected", "verdict": "letter", "assert": "main and develop both exist and are protected.", "intentRef": "AGENTS.md#branching-model" }, - { "id": "branch.ruleset.develop", "verdict": "letter", "assert": "The live develop ruleset matches repo-config/develop.json (normalized diff).", "intentRef": "repo-config/README.md" }, - { "id": "branch.ruleset.main", "verdict": "letter", "assert": "The live main ruleset matches repo-config/main.json (normalized diff).", "intentRef": "repo-config/README.md" } + { "id": "branch.ruleset.develop", "verdict": "letter", "assert": "The live develop ruleset matches the expected payload for the repo's workflowModel (normalized diff): release -> repo-config/develop.json (PR-gated), operational -> repo-config/operational/develop.json (direct signed pushes; deletion + non_fast_forward + required_signatures only).", "intentRef": "repo-config/README.md" }, + { "id": "branch.ruleset.main", "verdict": "letter", "assert": "The live main ruleset matches repo-config/main.json (normalized diff); this ruleset is shared by both workflow models.", "intentRef": "repo-config/README.md" }, + { "id": "branch.operational.lintci", "verdict": "intent", "assert": "An operational (workflowModel) repo runs a lint/validation CI (editorconfig/EOL plus domain linters, e.g. Home Assistant or ESPHome config validation or a firmware build; no unit testing) feeding the required Check pull request workflow status job, so the develop -> main promotion PR is gated even though develop takes direct commits. N/A for release repos.", "intentRef": "AGENTS.md#branching-model" } ] }, "repo-setup": { diff --git a/spec/validate.py b/spec/validate.py index 6ce1d639..8efa894e 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -90,6 +90,12 @@ def check_secret_set(label, entry, need_kind): print(f" - {e}") return 1 + # defaults.workflowModel feeds configure.sh's fallback, so an invalid value here breaks the apply while every + # per-repo entry still validates - check it once. + default_model = repos.get("defaults", {}).get("workflowModel") + if default_model is not None and default_model not in ("release", "operational"): + errors.append(f"defaults.workflowModel '{default_model}' invalid (expected release or operational)") + for i, repo in enumerate(repos["repos"]): if not isinstance(repo, dict): errors.append(f"repo #{i} is not an object") @@ -114,6 +120,10 @@ def check_secret_set(label, entry, need_kind): if t not in known_types: errors.append(f"{name}: type '{t}' not defined in project-types.json") + model = repo.get("workflowModel") + if model is not None and model not in ("release", "operational"): + errors.append(f"{name}: workflowModel '{model}' invalid (expected release or operational)") + required = set(repo.get("requiredSecrets", [])) for pub in repo.get("publish", []): if not isinstance(pub, dict) or "target" not in pub or "mechanism" not in pub: From b3a0827f427be75a98ba4028cbded5ac039a47db Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 13 Jul 2026 09:33:20 -0700 Subject: [PATCH 2/6] Operational repos: line endings follow the consuming app's platform (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A config repo is a *view into an application's configuration directory* (often the exact tree mounted into that app's container), so its files must use the line ending the app itself reads and writes - not the fleet CRLF default. Encode this per repo instead of forcing one rule on all config repos. - **AGENTS.md "Line Endings"**: operational repos set the global `[*] end_of_line` default to the consuming app's native platform - **LF** for Linux-native/container config (ESPHome, Home Assistant, devcontainer-only/HACS), **CRLF** for a Windows-native editor (Vantage-Config / Design Center). Execution-sensitive LF pins still apply; do not re-normalize such a repo to the fleet default. - **registry**: new `lineEndings` field (`lf` | `crlf`); set `lf` on ESPHome-Config, HomeAssistant-Config, HomeAutomation and `crlf` on Vantage-Config; schema + `validate.py` enforce the enum. - **spec/project-types.json** `recurring.eol`: the global default is CRLF for release repos or the registry `lineEndings` value for operational repos. ## Context Surfaced during the operational-repo rollout: ESPHome-Config uses a global `end_of_line = lf` (it is edited as the ESPHome container's `/config` view). Matching the app's platform is correct; converging it to fleet-CRLF would be over-normalization. ## Verification `python3 spec/validate.py` passes (rejects an invalid `lineEndings`); markdownlint + cspell clean on AGENTS.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + registry/repos.json | 4 ++++ registry/repos.schema.json | 9 +++++++++ spec/project-types.json | 2 +- spec/validate.py | 8 ++++++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 448a65c4..1cf9c73f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,7 @@ Applies to code and workflow (`#`) comments alike. - **[`.editorconfig`](./.editorconfig) sets the line ending:** `[*] end_of_line = crlf` is the **default** - every file type is CRLF unless pinned otherwise - with **LF** pinned for the execution-sensitive exceptions - `*.sh`, Dockerfiles, and any individual `.py` executed directly via its shebang (pinned **by path**, e.g. `spec/validate.py`; vanilla `.py` stays CRLF, since Python's universal newlines accept it and it is commonly edited on Windows). Only the LF exceptions are declared; the redundant per-type CRLF rules are intentionally omitted. `.gitattributes` mirrors it: `* -text` (git stores the exact bytes you commit and will **not** normalize) plus the matching LF pins. - **Choosing an ending for a new file type:** CRLF is the **default** - cross-platform editors on Windows produce it, and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **Non-workflow YAML stays CRLF** - GitHub Actions' parser tolerates it (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in `.editorconfig` - Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This (and the catalog snippet workflows in `catalog/snippets/workflows/*`, pinned LF the same way) is an LF class **not** backed by a `.gitattributes` pin: git keeps `* -text` (no normalization), and CI's `editorconfig-checker` (EOL-only) catches a mismatch instead. Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF. A config or pattern file consumed by a Linux tool stays CRLF when the tool tolerates a trailing CR: `.dockerignore` and `.gitignore` are CRLF (their parsers strip the CR), and only a *Dockerfile* - interpreted, where a CR breaks `RUN` heredocs and line continuations - is LF. +- **Operational (config) repos: the global default follows the consuming application's native platform, not the fleet CRLF default.** A config repo (registry `workflowModel: operational`) is a *view into an application's configuration directory* - often the exact tree mounted into that app's container - so its files must use the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry [`lineEndings`](./registry/repos.json) field (`lf` | `crlf`): **LF** for a Linux-native app whose config lives in a Linux container - ESPHome, Home Assistant, a devcontainer-only or HACS config - and **CRLF** for a Windows-native editor - e.g. Vantage-Config, edited by Design Center on Windows. The execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and `.gitattributes` still mirrors the chosen default. This override is for operational repos only; `release` repos keep the `[*] end_of_line = crlf` fleet default above. Do **not** re-normalize such a repo to the fleet default - that is exactly the over-normalization these per-repo endings prevent. - **Scripts and extensionless executables must be LF - and pinned in `.gitattributes`, not just configured.** A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that extension-based rule does not match **extensionless** executables (s6 service scripts `run`/`up`/`finish`, husky/git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing - so a broad normalization pass or an editor can silently flip them to CRLF (it has). `.gitattributes` is the enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless scripts **adds the matching path pin** - e.g. `Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text eol=lf` for husky hooks - so git holds them at LF on checkout and `--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky/git hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to **tool-owned file formats the base config doesn't key on**: pin them to whatever ending the tool reads and writes so a normalization sweep can't churn them - e.g. KiCad project/footprint/3D files (`*.kicad_mod`, `*.kicad_sym`, `*.step`), which KiCad writes LF (`*.kicad_mod text eol=lf`, ...). The principle is general: a file class the `.editorconfig` extension rules and `* -text` don't cover needs an explicit `.gitattributes` pin matching its tool's native ending. - **Pair each such pin with a matching `.editorconfig` override - the git pin alone is not enough.** `.gitattributes` governs **git** (checkout, commit, `--renormalize`); the **editor** follows `.editorconfig`, where the `[*] end_of_line = crlf` default still applies to any file no extension rule covers. So even with the git pin, the editor writes a CRLF shebang into an extensionless hook (breaking it when run from the working tree) or re-ends/trims a byte-sensitive data file. Give every extensionless **executable** an editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`); and for a **byte-preserve data directory** (downloaded or opaque source whose exact bytes the consumer may depend on) disable *all* editor normalization, not just EOL - `[/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value that removes an inherited property, so the editor enforces neither the global `charset` nor `end_of_line` on that path). Keep these overrides with the line-ending governance (above any `.NET-only` divider), not in the language-style section. - **New files:** create them with the `.editorconfig`-mandated ending. diff --git a/registry/repos.json b/registry/repos.json index ce49b6ab..13db735b 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -163,6 +163,7 @@ "types": ["source-only"], "groundTruthBranch": "develop", "workflowModel": "operational", + "lineEndings": "lf", "hasDevelop": true, "publish": [{ "target": "github-release", "mechanism": "none" }], "requiredSecrets": [], @@ -203,6 +204,7 @@ "types": ["source-only"], "groundTruthBranch": "develop", "workflowModel": "operational", + "lineEndings": "lf", "hasDevelop": true, "publish": [{ "target": "github-release", "mechanism": "none" }], "requiredSecrets": [], @@ -217,6 +219,7 @@ "types": ["source-only"], "groundTruthBranch": "main", "workflowModel": "operational", + "lineEndings": "lf", "hasDevelop": true, "publish": [{ "target": "github-release", "mechanism": "none" }], "requiredSecrets": [], @@ -283,6 +286,7 @@ "types": ["source-only"], "groundTruthBranch": "develop", "workflowModel": "operational", + "lineEndings": "crlf", "hasDevelop": true, "publish": [{ "target": "github-release", "mechanism": "none" }], "requiredSecrets": [], diff --git a/registry/repos.schema.json b/registry/repos.schema.json index 6f4a56a3..a10fe14d 100644 --- a/registry/repos.schema.json +++ b/registry/repos.schema.json @@ -25,11 +25,19 @@ "$defs": { "releaseTrigger": { "enum": ["two-phase", "publish-on-merge", "dispatch-only", "none"] }, "workflowModel": { "enum": ["release", "operational"] }, + "lineEndings": { "enum": ["lf", "crlf"] }, "mechanism": { "enum": ["oidc", "static-secret", "none"] }, "target": { "enum": ["nuget", "pypi", "docker", "github-release"] }, "repo": { "type": "object", "required": ["name", "url", "status"], + "allOf": [ + { + "comment": "An operational repo must declare its line endings (release repos use the fleet CRLF default).", + "if": { "properties": { "workflowModel": { "const": "operational" } }, "required": ["workflowModel"] }, + "then": { "required": ["lineEndings"] } + } + ], "additionalProperties": false, "properties": { "name": { "type": "string" }, @@ -39,6 +47,7 @@ "classificationPending": { "type": "boolean" }, "groundTruthBranch": { "type": "string" }, "workflowModel": { "$ref": "#/$defs/workflowModel" }, + "lineEndings": { "$ref": "#/$defs/lineEndings" }, "hasDevelop": { "type": "boolean" }, "publish": { "type": "array", diff --git a/spec/project-types.json b/spec/project-types.json index aca27749..06598cbc 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -137,7 +137,7 @@ { "id": "recurring.comments", "verdict": "letter", "assert": "Comments are concise, only the non-obvious, no prose narration, and do not grow on re-edit.", "intentRef": "AGENTS.md#comments" }, { "id": "recurring.charset", "verdict": "letter", "assert": "ASCII only in agent-authored text: no em-dash (use a spaced hyphen), no smart quotes, no stray non-ASCII.", "intentRef": "AGENTS.md#character-set" }, { "id": "recurring.spelling", "verdict": "letter", "assert": "US English spelling; the shared cspell.json sets language en-US (a bare en accepts British spellings too).", "intentRef": "CODESTYLE.md#markdown-and-spelling" }, - { "id": "recurring.eol", "verdict": "letter", "assert": "Line endings follow .editorconfig, which carries a global [*] end_of_line = crlf default plus LF pins for execution-sensitive files (shell, Dockerfiles, shebang-executable .py by path), plus workflow YAML in .github/workflows/* enforced by editorconfig-checker in CI; a per-extension-only form lacking the global [*] default is a drift finding. Edits preserve the file's endings.", "intentRef": "AGENTS.md#line-endings" } + { "id": "recurring.eol", "verdict": "letter", "assert": "Line endings follow .editorconfig, which carries a global [*] end_of_line default plus LF pins for execution-sensitive files (shell, Dockerfiles, shebang-executable .py by path), plus workflow YAML in .github/workflows/* enforced by editorconfig-checker in CI; a per-extension-only form lacking the global [*] default is a drift finding. The global default is CRLF for release repos, or the consuming application's native platform for an operational (config) repo as recorded in the registry lineEndings field (LF for Linux-native/container config e.g. ESPHome/Home Assistant, CRLF for a Windows-native editor e.g. Vantage/Design Center) - do not re-normalize such a repo to CRLF. Edits preserve the file's endings.", "intentRef": "AGENTS.md#line-endings" } ] }, "readme-structure": { diff --git a/spec/validate.py b/spec/validate.py index 8efa894e..00b89988 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -124,6 +124,14 @@ def check_secret_set(label, entry, need_kind): if model is not None and model not in ("release", "operational"): errors.append(f"{name}: workflowModel '{model}' invalid (expected release or operational)") + eol = repo.get("lineEndings") + if eol is not None and eol not in ("lf", "crlf"): + errors.append(f"{name}: lineEndings '{eol}' invalid (expected lf or crlf)") + # An operational repo's endings follow the consuming app's platform, so they must be declared; a release + # repo omits the field and uses the fleet CRLF default. + if model == "operational" and eol is None: + errors.append(f"{name}: operational repo must declare lineEndings (lf or crlf)") + required = set(repo.get("requiredSecrets", [])) for pub in repo.get("publish", []): if not isinstance(pub, dict) or "target" not in pub or "mechanism" not in pub: From a354c6e67ea4a2219fcbe14bd681c99ef18386bc Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 13 Jul 2026 10:09:01 -0700 Subject: [PATCH 3/6] EOL rule: mixed-consumer repos; drop standalone Vantage-Config (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up from the operational-repo rollout. - **AGENTS.md "Line Endings"**: document **mixed-consumer** operational repos - the registry `lineEndings` records the *primary* platform's default, and a subtree carries a per-path `.editorconfig` override for its own consumer (treated like any tool-owned format). Example: `HomeAutomation` is `lf`-global (Linux Proxmox host: docker-compose, shell, dnsmasq/unbound) with its Windows-edited `Vantage/**` Design Center files (UTF-8 CRLF `.dc` XML) pinned `crlf`. - **registry**: remove `Vantage-Config`. Design Center is now freely available, so its installer archive is discarded; the Vantage config is consolidated into `HomeAutomation` (which already carries the newer snapshots). The consolidation + dual-EOL are recorded on the `HomeAutomation` entry. - **cspell**: add `dnsmasq`. ## Verification `python3 spec/validate.py` passes (20 cataloged); markdownlint + cspell clean on AGENTS.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 ++- cspell.json | 1 + registry/repos.json | 17 +---------------- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1cf9c73f..8726ec24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,8 @@ Applies to code and workflow (`#`) comments alike. - **[`.editorconfig`](./.editorconfig) sets the line ending:** `[*] end_of_line = crlf` is the **default** - every file type is CRLF unless pinned otherwise - with **LF** pinned for the execution-sensitive exceptions - `*.sh`, Dockerfiles, and any individual `.py` executed directly via its shebang (pinned **by path**, e.g. `spec/validate.py`; vanilla `.py` stays CRLF, since Python's universal newlines accept it and it is commonly edited on Windows). Only the LF exceptions are declared; the redundant per-type CRLF rules are intentionally omitted. `.gitattributes` mirrors it: `* -text` (git stores the exact bytes you commit and will **not** normalize) plus the matching LF pins. - **Choosing an ending for a new file type:** CRLF is the **default** - cross-platform editors on Windows produce it, and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **Non-workflow YAML stays CRLF** - GitHub Actions' parser tolerates it (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in `.editorconfig` - Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This (and the catalog snippet workflows in `catalog/snippets/workflows/*`, pinned LF the same way) is an LF class **not** backed by a `.gitattributes` pin: git keeps `* -text` (no normalization), and CI's `editorconfig-checker` (EOL-only) catches a mismatch instead. Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF. A config or pattern file consumed by a Linux tool stays CRLF when the tool tolerates a trailing CR: `.dockerignore` and `.gitignore` are CRLF (their parsers strip the CR), and only a *Dockerfile* - interpreted, where a CR breaks `RUN` heredocs and line continuations - is LF. -- **Operational (config) repos: the global default follows the consuming application's native platform, not the fleet CRLF default.** A config repo (registry `workflowModel: operational`) is a *view into an application's configuration directory* - often the exact tree mounted into that app's container - so its files must use the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry [`lineEndings`](./registry/repos.json) field (`lf` | `crlf`): **LF** for a Linux-native app whose config lives in a Linux container - ESPHome, Home Assistant, a devcontainer-only or HACS config - and **CRLF** for a Windows-native editor - e.g. Vantage-Config, edited by Design Center on Windows. The execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and `.gitattributes` still mirrors the chosen default. This override is for operational repos only; `release` repos keep the `[*] end_of_line = crlf` fleet default above. Do **not** re-normalize such a repo to the fleet default - that is exactly the over-normalization these per-repo endings prevent. +- **Operational (config) repos: the global default follows the consuming application's native platform, not the fleet CRLF default.** A config repo (registry `workflowModel: operational`) is a *view into an application's configuration directory* - often the exact tree mounted into that app's container - so its files must use the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry [`lineEndings`](./registry/repos.json) field (`lf` | `crlf`): **LF** for a Linux-native app whose config lives in a Linux container - ESPHome, Home Assistant, a devcontainer-only or HACS config - and **CRLF** for a Windows-native editor - e.g. Vantage InFusion config edited by Design Center on Windows. The execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and `.gitattributes` still mirrors the chosen default. This override is for operational repos only; `release` repos keep the `[*] end_of_line = crlf` fleet default above. Do **not** re-normalize such a repo to the fleet default - that is exactly the over-normalization these per-repo endings prevent. + - **Mixed-consumer repos: global default = the primary consumer, with a per-path override for a differently-consumed subtree.** When one repo is consumed on two platforms, the registry `lineEndings` records the **primary** default and a subtree carries an `.editorconfig` path override (CRLF/LF) matching *its* consumer, treated like any tool-owned format (pin it; it is not fleet drift). Example: `HomeAutomation` is `lineEndings: lf` (Linux docker-compose, shell, and dnsmasq/unbound daemon configs on the Proxmox host), with its Windows-edited `Vantage/**` subtree - UTF-8 CRLF Design Center project files (`.dc`, which are really XML) - pinned `[Vantage/**] end_of_line = crlf`; the global `* -text` in `.gitattributes` already preserves those bytes, so no extra git pin is needed. - **Scripts and extensionless executables must be LF - and pinned in `.gitattributes`, not just configured.** A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that extension-based rule does not match **extensionless** executables (s6 service scripts `run`/`up`/`finish`, husky/git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing - so a broad normalization pass or an editor can silently flip them to CRLF (it has). `.gitattributes` is the enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless scripts **adds the matching path pin** - e.g. `Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text eol=lf` for husky hooks - so git holds them at LF on checkout and `--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky/git hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to **tool-owned file formats the base config doesn't key on**: pin them to whatever ending the tool reads and writes so a normalization sweep can't churn them - e.g. KiCad project/footprint/3D files (`*.kicad_mod`, `*.kicad_sym`, `*.step`), which KiCad writes LF (`*.kicad_mod text eol=lf`, ...). The principle is general: a file class the `.editorconfig` extension rules and `* -text` don't cover needs an explicit `.gitattributes` pin matching its tool's native ending. - **Pair each such pin with a matching `.editorconfig` override - the git pin alone is not enough.** `.gitattributes` governs **git** (checkout, commit, `--renormalize`); the **editor** follows `.editorconfig`, where the `[*] end_of_line = crlf` default still applies to any file no extension rule covers. So even with the git pin, the editor writes a CRLF shebang into an extensionless hook (breaking it when run from the working tree) or re-ends/trims a byte-sensitive data file. Give every extensionless **executable** an editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`); and for a **byte-preserve data directory** (downloaded or opaque source whose exact bytes the consumer may depend on) disable *all* editor normalization, not just EOL - `[/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value that removes an inherited property, so the editor enforces neither the global `charset` nor `end_of_line` on that path). Keep these overrides with the line-ending governance (above any `.NET-only` divider), not in the language-style section. - **New files:** create them with the `.editorconfig`-mandated ending. diff --git a/cspell.json b/cspell.json index 4d1e71a7..07f8024f 100644 --- a/cspell.json +++ b/cspell.json @@ -35,6 +35,7 @@ "debuglevel", "devcontainer", "distros", + "dnsmasq", "dockerbuild", "Dockerfiles", "dockerhub", diff --git a/registry/repos.json b/registry/repos.json index 13db735b..d3b4283f 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -169,7 +169,7 @@ "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "dispatch-only", - "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs).", "Private; README self-flags previously-committed secrets - secrets-hygiene concern.", "Operational rollout pending: lint CI feeding the required check, dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip), and develop-as-ground-truth adoption."] + "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs).", "Dual-platform EOL: lf global (Linux Proxmox host - docker/shell/dnsmasq/unbound) with the Windows-edited Vantage/** Design Center subtree (UTF-8 CRLF .dc XML) pinned crlf. Consolidates the former standalone Vantage-Config repo (removed from the registry; Design Center is now freely available, so its installer archives were discarded).", "Private; README self-flags previously-committed secrets - secrets-hygiene concern.", "Operational rollout pending: lint CI feeding the required check, dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip), and develop-as-ground-truth adoption."] }, { "name": "KiCadLibrary", @@ -279,21 +279,6 @@ "releaseTrigger": "none", "driftNotes": ["Work-in-progress: pre-CI (no .github/workflows, no version.json, no repo-config). main+develop both exist."] }, - { - "name": "Vantage-Config", - "url": "https://github.com/ptr727/Vantage-Config", - "status": "cataloged", - "types": ["source-only"], - "groundTruthBranch": "develop", - "workflowModel": "operational", - "lineEndings": "crlf", - "hasDevelop": true, - "publish": [{ "target": "github-release", "mechanism": "none" }], - "requiredSecrets": [], - "consumerModel": "pull", - "releaseTrigger": "dispatch-only", - "driftNotes": ["Maintainer config/asset archive (Vantage InFusion) with vendored binaries (MSI/7z/PDF) and versioned project snapshots.", "The lone FindInFile C# helper is incidental, not a governed artifact.", "Operational model: develop holds the ground truth; main is the last promoted snapshot. Rollout pending - no .github/workflows yet: lint CI feeding the required check plus dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip)."] - }, { "name": "HolidayLights", "url": "https://github.com/ptr727/HolidayLights", From fe9e4d1131b0fabd42fd5f008e90c6b9ce1d69a5 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 13 Jul 2026 11:01:18 -0700 Subject: [PATCH 4/6] Rename HomeAutomation -> HomeAutomation-Config; prefer split over mixed-EOL repos (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Rename** the registry entry `HomeAutomation` -> `HomeAutomation-Config` (name + url) for fleet naming consistency - config repos are `*-Config` (`ESPHome-Config`, `HomeAssistant-Config`, `Vantage-Config`). The GitHub repo is already renamed; old URLs redirect. - **AGENTS.md "Line Endings"**: rewrite the mixed-consumer guidance to lead with the *preferred* answer - **split by platform into single-platform repos** (the Vantage/Design Center config goes to its own Windows/CRLF `Vantage-Config`, not a `Vantage/**` subtree in the `lf` `HomeAutomation-Config`). The per-path `.editorconfig` override survives only as a fallback for a subtree that genuinely cannot be split. This supersedes the `#289` "HomeAutomation is a mixed repo" example, which the split decision makes obsolete. ## Context During the operational-repo rollout we decided to keep config repos single-platform: recreate a lean `Vantage-Config` (Windows/CRLF) rather than carry Vantage as a CRLF bolt-on inside the otherwise-Linux HomeAutomation repo. ## Verification `python3 spec/validate.py` passes (20 cataloged); markdownlint + cspell clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- registry/repos.json | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8726ec24..31462b3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,7 @@ Applies to code and workflow (`#`) comments alike. - **[`.editorconfig`](./.editorconfig) sets the line ending:** `[*] end_of_line = crlf` is the **default** - every file type is CRLF unless pinned otherwise - with **LF** pinned for the execution-sensitive exceptions - `*.sh`, Dockerfiles, and any individual `.py` executed directly via its shebang (pinned **by path**, e.g. `spec/validate.py`; vanilla `.py` stays CRLF, since Python's universal newlines accept it and it is commonly edited on Windows). Only the LF exceptions are declared; the redundant per-type CRLF rules are intentionally omitted. `.gitattributes` mirrors it: `* -text` (git stores the exact bytes you commit and will **not** normalize) plus the matching LF pins. - **Choosing an ending for a new file type:** CRLF is the **default** - cross-platform editors on Windows produce it, and it is harmless on Linux for everything except shell. Use LF only when the type **requires** it or CRLF **breaks how it is consumed**: executable scripts/shebangs (`*.sh`, s6, husky), Dockerfiles (CRLF breaks `RUN` heredocs/continuations), and tool-owned formats with a native LF ending (KiCad). **Non-workflow YAML stays CRLF** - GitHub Actions' parser tolerates it (a repo that also runs yamllint sets `new-lines: disable` to defer to `.editorconfig`). **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in `.editorconfig` - Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump. This (and the catalog snippet workflows in `catalog/snippets/workflows/*`, pinned LF the same way) is an LF class **not** backed by a `.gitattributes` pin: git keeps `* -text` (no normalization), and CI's `editorconfig-checker` (EOL-only) catches a mismatch instead. Distinguish where a file is *consumed* from where it is *edited*: consumption on Linux alone does not force LF. A config or pattern file consumed by a Linux tool stays CRLF when the tool tolerates a trailing CR: `.dockerignore` and `.gitignore` are CRLF (their parsers strip the CR), and only a *Dockerfile* - interpreted, where a CR breaks `RUN` heredocs and line continuations - is LF. - **Operational (config) repos: the global default follows the consuming application's native platform, not the fleet CRLF default.** A config repo (registry `workflowModel: operational`) is a *view into an application's configuration directory* - often the exact tree mounted into that app's container - so its files must use the ending the app itself reads and writes, and forcing the fleet CRLF default would fight the app. Set the `[*] end_of_line` default to the app's native ending and record it in the registry [`lineEndings`](./registry/repos.json) field (`lf` | `crlf`): **LF** for a Linux-native app whose config lives in a Linux container - ESPHome, Home Assistant, a devcontainer-only or HACS config - and **CRLF** for a Windows-native editor - e.g. Vantage InFusion config edited by Design Center on Windows. The execution-sensitive LF pins (`*.sh`, Dockerfiles, workflow YAML) still apply on top, and `.gitattributes` still mirrors the chosen default. This override is for operational repos only; `release` repos keep the `[*] end_of_line = crlf` fleet default above. Do **not** re-normalize such a repo to the fleet default - that is exactly the over-normalization these per-repo endings prevent. - - **Mixed-consumer repos: global default = the primary consumer, with a per-path override for a differently-consumed subtree.** When one repo is consumed on two platforms, the registry `lineEndings` records the **primary** default and a subtree carries an `.editorconfig` path override (CRLF/LF) matching *its* consumer, treated like any tool-owned format (pin it; it is not fleet drift). Example: `HomeAutomation` is `lineEndings: lf` (Linux docker-compose, shell, and dnsmasq/unbound daemon configs on the Proxmox host), with its Windows-edited `Vantage/**` subtree - UTF-8 CRLF Design Center project files (`.dc`, which are really XML) - pinned `[Vantage/**] end_of_line = crlf`; the global `* -text` in `.gitattributes` already preserves those bytes, so no extra git pin is needed. + - **Mixed-consumer config: prefer to split by platform into single-platform repos, not one mixed repo.** When a config repo would be consumed on two platforms (a Linux app plus a Windows-edited subtree), the clean answer is a repo per consumer, each single-platform with its own `lineEndings` - e.g. the Vantage InFusion / Design Center config (Windows/CRLF) lives in its own `Vantage-Config` repo, **not** as a `Vantage/**` subtree inside the Linux-`lf` `HomeAutomation-Config`. That keeps each repo's default, CI, and checkout matched to one platform and avoids per-path EOL machinery entirely. **Fallback only if a subtree genuinely cannot be split out:** keep the global default at the primary consumer and pin the odd subtree with an `.editorconfig` path override (e.g. `[/**] end_of_line = crlf`) matching its consumer, treated like any tool-owned format; the global `* -text` in `.gitattributes` already preserves those bytes, so no extra git pin is needed. - **Scripts and extensionless executables must be LF - and pinned in `.gitattributes`, not just configured.** A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution. `.editorconfig` sets `[*.sh] = lf`, but that extension-based rule does not match **extensionless** executables (s6 service scripts `run`/`up`/`finish`, husky/git hook scripts like `.husky/pre-commit`), and `* -text` enforces nothing - so a broad normalization pass or an editor can silently flip them to CRLF (it has). `.gitattributes` is the enforcement layer: it carries `*.sh text eol=lf`, and any repo whose tooling ships extensionless scripts **adds the matching path pin** - e.g. `Docker/s6-overlay/** text eol=lf` for s6 init, `.husky/pre-commit text eol=lf` for husky hooks - so git holds them at LF on checkout and `--renormalize`. This pin is mandatory for any repo that overrides s6 init, uses husky/git hooks, or otherwise ships executable scripts. The same explicit-pin rule extends to **tool-owned file formats the base config doesn't key on**: pin them to whatever ending the tool reads and writes so a normalization sweep can't churn them - e.g. KiCad project/footprint/3D files (`*.kicad_mod`, `*.kicad_sym`, `*.step`), which KiCad writes LF (`*.kicad_mod text eol=lf`, ...). The principle is general: a file class the `.editorconfig` extension rules and `* -text` don't cover needs an explicit `.gitattributes` pin matching its tool's native ending. - **Pair each such pin with a matching `.editorconfig` override - the git pin alone is not enough.** `.gitattributes` governs **git** (checkout, commit, `--renormalize`); the **editor** follows `.editorconfig`, where the `[*] end_of_line = crlf` default still applies to any file no extension rule covers. So even with the git pin, the editor writes a CRLF shebang into an extensionless hook (breaking it when run from the working tree) or re-ends/trims a byte-sensitive data file. Give every extensionless **executable** an editorconfig LF override beside its `.gitattributes` pin (`[.husky/pre-commit] end_of_line = lf`); and for a **byte-preserve data directory** (downloaded or opaque source whose exact bytes the consumer may depend on) disable *all* editor normalization, not just EOL - `[/*]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value that removes an inherited property, so the editor enforces neither the global `charset` nor `end_of_line` on that path). Keep these overrides with the line-ending governance (above any `.NET-only` divider), not in the language-style section. - **New files:** create them with the `.editorconfig`-mandated ending. diff --git a/registry/repos.json b/registry/repos.json index d3b4283f..2637f519 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -157,8 +157,8 @@ "driftNotes": ["Docker image wrapping upstream Nx products; C# (CreateMatrix) is the codegen generator, not a shipped package (IsPackable=false, no nuget push).", "Release is the two-phase model (weekly schedule + workflow_dispatch publish; ordinary merges do not) plus an extra Make/Matrix.json path-scoped push that republishes when the codegen version pin bumps.", "Docker Hub README published per-image via a Matrix.json-derived matrix.", "Branch hygiene: 3 stale Dependabot nuget branches (PRs closed/superseded) linger, safe to delete; main+develop otherwise clean after the 2026-07 sweep."] }, { - "name": "HomeAutomation", - "url": "https://github.com/ptr727/HomeAutomation", + "name": "HomeAutomation-Config", + "url": "https://github.com/ptr727/HomeAutomation-Config", "status": "cataloged", "types": ["source-only"], "groundTruthBranch": "develop", @@ -169,7 +169,7 @@ "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "dispatch-only", - "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs).", "Dual-platform EOL: lf global (Linux Proxmox host - docker/shell/dnsmasq/unbound) with the Windows-edited Vantage/** Design Center subtree (UTF-8 CRLF .dc XML) pinned crlf. Consolidates the former standalone Vantage-Config repo (removed from the registry; Design Center is now freely available, so its installer archives were discarded).", "Private; README self-flags previously-committed secrets - secrets-hygiene concern.", "Operational rollout pending: lint CI feeding the required check, dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip), and develop-as-ground-truth adoption."] + "driftNotes": ["Maintainer config/ops repo (docker-compose stacks, lifecycle scripts, Firewalla configs); Linux-consumed on the Proxmox host, so lineEndings lf.", "Renamed from HomeAutomation for fleet naming consistency (config repos are *-Config). The Vantage controller config is split out to its own Windows/CRLF Vantage-Config repo, not carried here - strip the legacy Vantage/ subtree during onboarding.", "Private; README self-flags previously-committed secrets - secrets-hygiene concern.", "Operational rollout pending: lint CI feeding the required check, dispatch-only source-release scaffolding (version.json + NBGV get-version + publish-release.yml, tag + source zip), and develop-as-ground-truth adoption."] }, { "name": "KiCadLibrary", @@ -279,6 +279,21 @@ "releaseTrigger": "none", "driftNotes": ["Work-in-progress: pre-CI (no .github/workflows, no version.json, no repo-config). main+develop both exist."] }, + { + "name": "Vantage-Config", + "url": "https://github.com/ptr727/Vantage-Config", + "status": "cataloged", + "types": ["source-only"], + "groundTruthBranch": "develop", + "workflowModel": "operational", + "lineEndings": "crlf", + "hasDevelop": true, + "publish": [{ "target": "github-release", "mechanism": "none" }], + "requiredSecrets": [], + "consumerModel": "pull", + "releaseTrigger": "dispatch-only", + "driftNotes": ["Vantage InFusion / Design Center controller config edited on Windows (UTF-8 CRLF .dc XML, really special XML), so lineEndings crlf.", "Recreated lean and single-platform: Design Center is freely available, so no installer archives are kept; split out of HomeAutomation-Config. Being repopulated from the Windows editing host - operational onboarding (baseline, lint CI, dispatch-only publisher, rulesets, develop/main) pending once content lands."] + }, { "name": "HolidayLights", "url": "https://github.com/ptr727/HolidayLights", From 3a0868505c7742321f15517fb967a53ffd182771 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 13 Jul 2026 12:38:55 -0700 Subject: [PATCH 5/6] CODESTYLE: defer line endings to AGENTS.md (don't hardcode CRLF) (#291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary CODESTYLE.md's line-ending item stated a fixed "CRLF for YAML/JSON/..." rule, which contradicts operational (Linux-consumed config) repos that use a global **LF** default per their `.editorconfig` and the AGENTS.md operational `lineEndings` rule. Line-ending governance already lives in AGENTS.md ("Line Endings"), so point to it instead of restating a fixed ending here. ## Context Surfaced by Copilot while reviewing HomeAssistant-Config (an `lf` operational repo) carrying CODESTYLE.md verbatim - the hardcoded CRLF line read as misleading guidance for that repo. ## Verification markdownlint + cspell clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CODESTYLE.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CODESTYLE.md b/CODESTYLE.md index 4fcdaf91..6c95f6bc 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -187,9 +187,7 @@ Note: Code snippets are illustrative examples only. Replace namespaces/types to - YAML files: 2 spaces - JSON files: 4 spaces -5. **Line endings** - - C#, XML, YAML, JSON, Windows scripts: CRLF - - Linux scripts (`.sh`): LF +5. **Line endings**: not specified here - governed per repo by `.editorconfig` / `.gitattributes` per the [AGENTS.md][agents] "Line Endings" section. 6. **`#region`**: Do not use regions. Prefer logical file/folder/namespace organization. 7. **Member ordering (StyleCop SA1201)**: const -> static readonly -> static fields -> instance readonly fields -> instance fields -> constructors -> public (events -> properties -> indexers -> methods -> operators) -> non-public in same order -> nested types From 39b6495718b1197935153e87ab9ff811cc129c2f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 13 Jul 2026 13:28:37 -0700 Subject: [PATCH 6/6] Follow-up: effective-model lineEndings check + $comment keyword (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on the promotion PR #292: - `spec/validate.py`: require `lineEndings` by the effective workflow model (repo -> `defaults.workflowModel` -> release), matching `configure.sh`. - `registry/repos.schema.json`: use the standard `$comment` annotation keyword. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- registry/repos.schema.json | 2 +- spec/validate.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/registry/repos.schema.json b/registry/repos.schema.json index a10fe14d..032838f4 100644 --- a/registry/repos.schema.json +++ b/registry/repos.schema.json @@ -33,7 +33,7 @@ "required": ["name", "url", "status"], "allOf": [ { - "comment": "An operational repo must declare its line endings (release repos use the fleet CRLF default).", + "$comment": "An operational repo must declare its line endings (release repos use the fleet CRLF default).", "if": { "properties": { "workflowModel": { "const": "operational" } }, "required": ["workflowModel"] }, "then": { "required": ["lineEndings"] } } diff --git a/spec/validate.py b/spec/validate.py index 00b89988..6787404e 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -128,8 +128,11 @@ def check_secret_set(label, entry, need_kind): if eol is not None and eol not in ("lf", "crlf"): errors.append(f"{name}: lineEndings '{eol}' invalid (expected lf or crlf)") # An operational repo's endings follow the consuming app's platform, so they must be declared; a release - # repo omits the field and uses the fleet CRLF default. - if model == "operational" and eol is None: + # repo omits the field and uses the fleet CRLF default. Resolve the effective model the same way + # configure.sh does (repo -> defaults -> release) so the requirement holds even if a repo relies on an + # operational defaults.workflowModel rather than setting it explicitly. + effective_model = model or default_model or "release" + if effective_model == "operational" and eol is None: errors.append(f"{name}: operational repo must declare lineEndings (lf or crlf)") required = set(repo.get("requiredSecrets", []))