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: