From 958037b97ba3c539be3985481f9ad3893a3220ab Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 15:35:00 -0700 Subject: [PATCH 1/2] Extract operational-repo rules into one scoped section (#368, PR-3) Doc surgery on the granular-single-scope model. The operational-vs-release carve-out was scattered inline across Foundational Principles, Branching Model, and Release Model, so those all-downstream sections mixed two scopes a reader had to piece apart. - New "## Operational Repositories" section consolidates the operational workflow rules (direct-to-develop, advisory CI, the develop/main ruleset differences, dispatch-only release, dependabot/merge-bot on every tier). It is tagged appliesTo ["operational"] in spec/files.json, so PR-2's section-presence check requires it only on operational repos. - The three inline carve-outs slim to one-line pointers, leaving Foundational, Branching, and Release single-scope (the release model). No operational detail is lost - it all moves into the new section. - The operational line-ending rule stays in the Line Endings section (findable where EOL rules live) with a cross-pointer, rather than moving into the new section. Verified: operational repos require the new section and release repos do not, the hub's own AGENTS.md is complete, validate.py and the doc linters are clean. Once merged, the 4 operational repos will DRIFT on this section until they adopt it - the intended convergence signal. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 19 ++++++++++++++++--- spec/files.json | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b9d980..5918eea 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". (The auto-publish rules below describe `release` repos only; **operational** repos - registry `workflowModel: operational` - commit directly to `develop`, run no codegen or auto-publish bots, and cut a GitHub release **only** by manual `workflow_dispatch` (`releaseTrigger: dispatch-only`) - never automatically. Dependabot's dual-target sync and the App-signed merge-bot run on **every** tier, operational included. 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". (These auto-publish rules describe `release` repos. **Operational** repos differ - direct-to-`develop`, dispatch-only release - see "Operational Repositories".) - **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. @@ -34,7 +34,7 @@ A state-changing GitHub call is the highest-blast-radius thing an agent does her ## 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. +- **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 instead track a live service's running state and differ substantially - direct-to-`develop`, advisory CI, dispatch-only release - see "Operational Repositories". 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. @@ -56,7 +56,7 @@ A state-changing GitHub call is the highest-blast-radius thing an agent does her ## Release Model -The **two-phase model is the 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"). +The **two-phase model is the 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 differ - dispatch-only release, no auto-publish - see "Operational Repositories". - **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. @@ -79,6 +79,19 @@ The **two-phase model is the default**: PRs build fast, publishing is batched. S - **Issue-closing keywords (`Closes #N`, `Fixes #N`) go in the `develop -> main` promotion PR, not the feature -> develop PR.** GitHub auto-closes an issue only when the closing keyword merges into the **default branch** (`main`); a feature/develop PR merges into `develop`, so the keyword never fires there. Reference the issue in the develop PR body if useful, but put the actual closing keyword on the promotion PR. - **Wrapper repos that track an upstream release.** A repo wrapping an upstream release uses [`check-upstream-version-task.yml`](./catalog/snippets/workflows/check-upstream-version-task.yml): a resolver command prints the upstream version(s) as a **JSON object of `name -> version`**, written to a committed state file at the **repo root beside `version.json`** (default `upstream-version.json` - it is a build-input version source, not GitHub-platform config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the common single-version case (`{"version":"X"}`) or N keys for a wrapper that pins several upstream components (e.g. an image plus a companion tool), and the build reads each component by key; the bump PR's title/body name only the keys that actually moved. Call it from a scheduled entry-point workflow and matrix only the branches that ship the version (a CI-only version uses `["develop"]`). A merged bump ships on the **next publish**, not immediately - the two-phase latency tradeoff. +## Operational Repositories + +The registry [`workflowModel`](./registry/repos.json) field is `release` (the default) or `operational`. This section is the operational delta - every other rule in this file is the `release` model unless it says otherwise. + +**Operational** repos track a live service's running state rather than shipping versioned units of delivery - live-service config such as Home Assistant, ESPHome, Vantage, and home automation. + +- **Commit configuration directly to `develop`.** There is no feature branch - the maintainer commits straight to `develop` and *occasionally* opens a `develop -> main` PR to bless a known-good snapshot. The [`develop` ruleset](./repo-config/operational/develop.json) drops the PR and status-check gate, so 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` promotion gate is unchanged.** The [`main` ruleset](./repo-config/main.json) is shared with `release` repos, so the `develop -> main` PR still **enforces** the required `Check pull request workflow status job`. For an operational repo 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`. +- **Release only by manual dispatch.** Operational repos carry `releaseTrigger: dispatch-only` and run no codegen or auto-publish bots, so they publish **only** on a manual `workflow_dispatch` - the source-only release the publisher already supports (tag + source zip + README + LICENSE, NBGV-versioned), never automatically. The `develop -> main` promotion just blesses a known-good snapshot, and a release is a separate, deliberate dispatch. +- **Fleet sync still applies.** Dependabot's dual-target sync and the App-signed merge-bot run on **every** tier, operational included, so both branches stay in sync and a promotion stays a clean forward merge. + +Line-ending governance for an operational repo is in [Line Endings](#line-endings) - its `[*]` default follows the consuming app's native platform per the registry `lineEndings` field, not the fleet CRLF default. + ## Repository Onboarding and Conformance Every fleet repo is a standard-style repo the hub audits **downward** against its declared type - the model the fleet uses because managing downstream divergence is too costly. Three obligations follow, and they are not optional: diff --git a/spec/files.json b/spec/files.json index 7d3fd37..6dc60d0 100644 --- a/spec/files.json +++ b/spec/files.json @@ -2,7 +2,7 @@ "$schema": "./files.schema.json", "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit checks presence (letter) and equivalence (intent); a section for an absent language or target is N/A. Each entry, and each section, carries an appliesTo selector - see spec/scope-model.md for the scope model and selector vocabulary.", "baseline": [ - { "path": "AGENTS.md", "sections": ["Repository Boundaries and Write Safety", "Git and Commit Rules", "Branching Model", "Release Model", "Pull Request Title and Commit Message Conventions", "Documentation Style Conventions", "PR Review Etiquette", "Workflow YAML Conventions"], "intentRef": "AGENTS.md", "appliesTo": "*" }, + { "path": "AGENTS.md", "sections": ["Repository Boundaries and Write Safety", "Git and Commit Rules", "Branching Model", "Release Model", { "name": "Operational Repositories", "appliesTo": ["operational"] }, "Pull Request Title and Commit Message Conventions", "Documentation Style Conventions", "PR Review Etiquette", "Workflow YAML Conventions"], "intentRef": "AGENTS.md", "appliesTo": "*" }, { "path": "CODESTYLE.md", "whole": true, "placeholders": ["InternalsVisibleTo project names"], "intentRef": "CODESTYLE.md", "appliesTo": "*" }, { "path": "WORKFLOW.md", "whole": true, "intentRef": "WORKFLOW.md", "appliesTo": "*" }, { "path": "README.md", "appliesTo": "*" }, From 93b084eb9ecf977f96d8691e709ee4a3c8465266 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 15:38:04 -0700 Subject: [PATCH 2/2] Drop the clause-joining semicolon on the edited Release Model line (#371) Copilot round-1 finding: the Release Model intro line (edited in this PR) joined two clauses with a semicolon. Split into two sentences. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5918eea..41c0158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ A state-changing GitHub call is the highest-blast-radius thing an agent does her ## Release Model -The **two-phase model is the 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 differ - dispatch-only release, no auto-publish - see "Operational Repositories". +The **two-phase model is the default**: PRs build fast, publishing is batched. See [README "Release Distribution Model"](./WORKFLOW.md) for the full rationale. The load-bearing rules follow. The auto-publish paths (bot push, schedule) apply to `release` repos. **Operational** repos differ - dispatch-only release, no auto-publish - see "Operational Repositories". - **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.