Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .agents/skills/git-commit-conventions/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
name: git-commit-conventions
description: >-
Governs how an agent stages, commits, signs, and pushes in a ptr727/ProjectTemplate fleet repo:
default-to-staging vs. explicit commit authorization, why "commit" means commit-and-push, the
mandatory signed-commit and noreply-identity checks, never force-pushing, how a history rewrite
must re-identify a commit that is not the agent's own, and the destructive-git-command ban. Use
this whenever about to run git add/commit/push, whenever authorization to commit is ambiguous
("fix this" versus "commit this"), whenever about to configure or verify commit signing or
git user.email, whenever a merge conflict or a stale branch tempts a force-push or a hard reset,
and whenever rewriting history (filter-repo, an interactive rebase equivalent) touches a commit
authored or committed by someone else. Triggers even when the task looks like routine
housekeeping, such as "clean up this branch" or "just push it", because a scope-widened commit
authorization, an unsigned commit, a fabricated identity, or a force-push are each easy to do by
habit and each one is a hard-to-reverse mistake on a shared branch.
---

# Git Commit Conventions

## Why this exists

These are the fleet's mechanical git rules for producing a commit, kept in one place instead of
re-derived per repo or per session: whether to commit at all, what committing implies, how
signing and identity are verified rather than configured, and which commands are never run
without being asked. None of these are style preferences. Branch protection enforces several of
them at push time, and the rest guard against damage a rejected push does not undo (a
scope-widened commit, a rewritten shared history, a destructive reset).

## Staging versus committing

- **Default to staging, not committing.** Stage with `git add` and leave `git commit` to the
developer unless the developer has explicitly authorized committing for the current ask ("commit
this", "open a PR"). Authorization is scope-bound: it covers the commits that specific task
needs, not a blanket license for the rest of the session.
- **"Commit" means commit and push.** An authorization to commit carries the push to the feature
branch the work belongs on, because nothing reviews a local commit. The Copilot review loop, the
required status checks, and the maintainer all read the remote, so stopping at `git commit`
leaves the review unstarted and the branch's state private to one machine, which reads as
progress while none of the gates have run. Push to the feature branch, never to a protected
branch, and never with `--force`. Holding a commit locally is the narrower case: it happens when
the developer asks for it, not by default.
- **Check `git status` for the maintainer's own uncommitted edits before committing.** The
maintainer hand-edits files live, often `README.md`/`HISTORY.md`, sometimes with an editor's
LF -> CRLF flip on top. If there are changes not made this session, ask whether to include them
rather than bundling half-finished work or stranding it in an unrelated commit.

## Signing, verified not configured

- **Every commit must be cryptographically signed (SSH or GPG).** Branch protection enforces this
on every fleet branch, and an unsigned commit is rejected on push. Signing depends on
environment configuration: `git config commit.gpgsign true`, a configured `user.signingkey`, and
a working signing agent (`ssh-agent` for SSH, `gpg-agent` for GPG). **If signing is not
configured, do not commit.** Surface the missing config to the developer and stop at `git add`.
Verify before the first agent-authored commit, don't assume a prior session left it set:
`git config --get commit.gpgsign && ssh-add -L`, or the GPG equivalent.
- **Signing must be live before the *first* commit, not retrofitted.** Turning on a
require-signed-commits rule against a branch that already carries unsigned commits forces a
rewrite of that entire history to re-sign it, changing every commit SHA and making whoever does
the rewrite the committer and signer of every commit in it (a rebase preserves `author` but not
the original signatures, and one contributor cannot sign for another). During new-repo setup,
never create commits until signing is verified.

## Identity, verified not set

**Commit under the committing account's own GitHub `noreply` identity, never a private, personal,
or invented address.** `author` and `committer` on every agent-authored commit are the GitHub
`noreply` address of the account whose key signs the commit, in `username@users.noreply.github.com`
or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: check
`git config --get user.email` matches that address before committing, rather than writing a
repo-local override. The identity is host configuration set globally once, so a repo-local
`user.email` is redundant where the global is right and a silently-shadowing wrong identity where
it is not. A mismatch is a host fault to surface to the maintainer, not to patch per repo, because
a local override hides a broken host that then commits wrong in every other repo on that machine.
A wrong identity is not cosmetic: a private email trips GitHub's email-privacy push protection, and
an invented author pollutes history. It is also a distinct failure from signing (a wrong author
does not by itself fail the signature check), though the ad-hoc identities that produce one are
typically also unsigned, which the signing rule above then rejects independently.

## Never force push

Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force
pushing rewrites shared history and can cause data loss. This holds regardless of how confident
the rewrite looks, a rejected push is recoverable, a force-pushed one is not.

## History rewrites re-identify only what changed

**A history rewrite includes only the commits that must change, and re-identifies any commit it
rewrites that is not the agent's own.** Filtering history (`git filter-repo` or an equivalent, for
example to strip PII) re-signs every commit it touches with the rewriter's own key, while the
tooling preserves each commit's original `author`/`committer` unless told otherwise. GitHub
verifies a signature against the commit's `committer` identity, so a signature from the rewriter's
key over a commit still committed by a bot (`dependabot[bot]`, `github-actions[bot]`) or GitHub's
own web-flow does not match its committer and lands `unknown_key`/unverified, which a
require-signed-commits rule then rejects.

Two gates keep committer and signature aligned:

1. **Scope the rewrite to only the commits that must be modified.** By default those are the
rewriter's own, whose committer already matches, so a commit that needs no change stays out of
the rewrite entirely and its identity and signature are never touched.
2. **If a commit that must change is not the rewriter's own, set its `committer` to the rewriter's
own signing identity before re-signing** (and its `author` too, since a rewrite that alters
content should not keep attributing it to the bot). The original bot attribution is deliberately
given up as the cost of having to rewrite it.

Never leave a signature over a commit committed by another identity. Verify after any rewrite that
every rewritten commit is signed and committed under the correct identity
(`git log --show-signature`).

## Never run destructive git commands without being asked

`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`, and anything else that
discards uncommitted work runs only on explicit developer instruction, never as a convenience step
inside a larger task.
154 changes: 154 additions & 0 deletions .agents/skills/operational-vs-release-workflow/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
---
name: operational-vs-release-workflow
description: >-
Governs how a ptr727/ProjectTemplate fleet repo branches, promotes, and publishes: the
feature -> develop -> main flow, squash-only vs. merge-commit-only branch protection, the two
develop -> main promotion traps (never delete develop, EOL-only conflicts), the two-phase
publish model (PRs smoke-test only, a human merge never auto-publishes), NBGV semantic
versioning, and the operational-repo delta (direct-to-develop commits, advisory CI, dispatch-only
release) that applies instead whenever the registry's workflowModel field for this repo reads
operational rather than release. Use this whenever choosing a target branch for a change,
promoting develop to main, resolving a develop -> main merge conflict, deciding whether a
release repo's config change needs a PR versus an operational repo's config change can commit
straight to develop, bumping version.json, adding or dropping a release target, or reasoning
about why a merge did or didn't trigger a publish. Triggers even when the request sounds like
ordinary git housekeeping ("just push this config fix", "merge develop into main", "cut a
release"), because the two workflow models genuinely differ (a direct-to-develop commit that is
correct in an operational repo is a rule violation in a release repo, and vice versa) and
applying the wrong one is not obviously wrong to a reader who only knows one of the two.
---

# Operational vs. Release Workflow

## Why this exists

Two workflow models exist because the underlying repos are two different things. Most fleet repos
ship versioned units of delivery, so they earn a feature -> `develop` -> `main` flow with real
release gates. A handful of repos instead track a live service's running state (Home Assistant,
ESPHome, Vantage, home automation configs) where the "release" is the config already committed,
not something built and shipped later. Applying the release model's ceremony to an operational
repo, or skipping the release model's gates on a repo that actually ships versioned artifacts, is
each wrong in its own repo and correct in the other, which is why this is one skill keyed on which
repo you're in rather than two skills that never talk to each other.

## Which model this repo uses

Read the registry `workflowModel` field for this repo (`release`, the default, or `operational`).
The rest of this skill's "Branching" and "Publishing" sections describe the `release` model. The
"Operational repositories" section below is the complete delta for `operational` repos. Anything
not mentioned there is unchanged. When in doubt which one applies, check `registry/repos.json`
rather than guessing from the repo's contents.

## Branching (release model)

- `develop` is the integration branch. Feature branches -> `develop` is **squash-only**, which
keeps `develop` 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. 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), see
`git-commit-conventions`. Squash and merge commits created via the GitHub UI are signed by
GitHub's web-flow key.
- **`develop` is forward-only, with no `main -> develop` back-merges.** The `develop` ruleset's
squash-only setting physically blocks merge commits on `develop`. Any historical back-merge
commits in `git log` predate this rule and must not be repeated.
- **Never delete `develop`, and take the EOL-only conflict by taking develop's side.** A
promotion PR's head *is* `develop`, so `--delete-branch` deletes it. An EOL-only conflict on a
workflow YAML file resolves on a throwaway branch off `main`, not on `develop`. Full recovery and
conflict-resolution commands: `references/branch-protection-and-promotion.md`.
- **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`), so a feature -> `develop` PR merge never fires it.
Reference the issue in the `develop` PR body if useful, but the actual closing keyword belongs on
the promotion PR. Closing by hand is the ordinary route wherever the keyword cannot fire (a
promotion that already merged without it, or completed work with no promotion imminent), not a
repair for a botched promotion, cite the squash SHA and re-read that commit before closing.
- **Neither ruleset requires branches to be up to date before merging**, for different reasons on
each branch (a graph-based check that would fail every release on `main`, a check that stalls
bot auto-merge on `develop`). Detail: `references/branch-protection-and-promotion.md`.
- **Configuring branch protection: import the committed ruleset payloads, don't hand-build them.**
Exactly two rulesets, named `develop` and `main`. Full procedure, including the operational
`develop` payload and the brownfield-repo signing caveat:
`references/branch-protection-and-promotion.md`.
- **Dependabot and codegen target both `main` and `develop` in parallel**, each branch absorbing
its own bot PRs independently so neither falls behind, with the merge-bot dispatching the merge
form (`--squash`/`--merge`) that matches each PR's base ruleset. Codegen output must be
deterministic from its inputs alone, never per-run state, or the two branches' legs conflict on
every promotion. Full mechanics: `references/branch-protection-and-promotion.md`.
- **App-token workflows authenticate with Client ID, not the deprecated App ID.** Use
`client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}` at any new App-token call site.

## Publishing (release model)

- **The two-phase model is the default: PRs build fast, publishing is batched.** A PR only
smoke-tests (unit tests plus a reduced build of the changed targets), it never pushes anything.
`publish-release.yml` is the sole publisher, and each run builds a **single trigger branch**
(`main` a release, `develop` a prerelease).
- **A human merge never auto-publishes.** Publishing fires on a **`workflow_dispatch`** of
`main`/`develop` (a human-initiated release), a **code-affecting bot push to `main`** (the
codegen App merging a Dependabot/codegen PR, gated on `github.actor` so a human
merge/promotion skips it), or a **weekly `schedule`** (Docker only, to refresh the base image).
A source-only repo publishes on dispatch only.
- **The changes-detection job is a required check that must succeed, not just not fail.** A
paths-filter error must never let a target-changing PR merge with its smoke build silently
skipped. A skipped smoke job (no matching change) passes, `failure`/`cancelled` blocks.
- **Versioning is semantic and maintainer-controlled.** `version.json`'s `major.minor` is the
version floor, edited by the maintainer for functional changes only, in the PR that introduces
the work, never on a fixed cadence or mechanically after a release. NBGV appends the git height
automatically on every commit, so a release always gets a fresh build version with **no
post-release bump** and no develop-ahead requirement.
- **Docs reference the 2-digit `major.minor` line, never a 3-digit build.** `README.md`,
`HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete
build height, which is both wrong (the real height differs) and a maintenance trap.
"Correcting" `1.0` to `1.0.0` is a defect.
- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the
version string, except Docker, which always re-pushes** to pick up upstream base-image
refreshes. Full guarantee and the `version.json` `pathFilters` boundary:
`references/release-publish-mechanics.md`.
- **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming
contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push,
an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all),
and tracking an upstream release from a wrapper repo: `references/release-publish-mechanics.md`.
See also `WORKFLOW.md` for the full CI/CD contract this section's rules are load-bearing
excerpts of.

## Operational repositories (the complete delta)

Everything above is the `release` model. An `operational` repo (registry `workflowModel:
operational`) tracks a live service's running state rather than shipping versioned units of
delivery, and differs from the `release` model in exactly these ways, everything not listed here
stays the same:

- **Commit configuration directly to `develop`.** There is no feature branch requirement, the
maintainer commits straight to `develop`, and only *occasionally* opens a `develop -> main` PR to
bless a known-good snapshot. The `develop` ruleset 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.
- **A PR into `develop` stays available, and CI runs on it, reported but not required.** Dropping
the requirement permits the direct push, it does not withdraw the pull request, so a change worth
reviewing takes one and both paths into `develop` are legitimate.
- **Take the pull request whenever the change is not one a reader takes in at a glance and
reverts cleanly.** What decides it is the shape of the change, not a line count: restructuring
rather than adjusting a value, touching several files at once, introducing a device, an
integration, or an automation that did not exist before, and anything whose failure shows up on
the live service rather than in a lint run are each the pull request case. So is a change the
author cannot state in one sentence. This stays a judgment call by design, adding a
`pull_request` rule to the operational `develop` ruleset would gate the direct push too and
withdraw the allowance the model exists to give.
- **The `main` promotion gate is unchanged.** The shared `main` ruleset still **enforces** the
required `Check pull request workflow status job` on the `develop -> main` PR. For an operational
repo that check is lint/validation only (editorconfig/EOL plus a domain linter such as a Home
Assistant or ESPHome config validation, 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, publishing **only** on a manual `workflow_dispatch` (the
same 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, 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 policy differs too**, following the consuming app's native platform rather than the
fleet CRLF default, per the registry `lineEndings` field. That rule belongs to
`comment-and-doc-style`, not repeated here.
Loading