mookie@dev:~/posts$ cat commits-convencionales-dotnet.md
Conventional Commits en .NET sin fiarte de que el dev se acuerde
Por qué un hook local no basta y cómo combinar un hook versionado que se instala con el build con un check obligatorio en el PR
Muchos equipos acaban usando Conventional Commits: tipo(ámbito): descripción. No es la única opción (hay equipos que usan el número del ticket, JIRA-123: ..., o su propio prefijo), pero es la que más herramientas entiende: changelogs automáticos, versionado semántico y un historial que se lee de un vistazo.
Los tipos más habituales:
| Tipo | Para qué se usa |
|---|---|
feat |
Una funcionalidad nueva |
fix |
La corrección de un bug |
docs |
Solo documentación |
style |
Formato del código, sin cambiar su comportamiento |
refactor |
Reestructurar código sin añadir funcionalidad ni corregir bugs |
perf |
Una mejora de rendimiento |
test |
Añadir o corregir tests |
build |
Cambios en el build o en las dependencias |
ci |
Cambios en los pipelines de CI |
chore |
Tareas de mantenimiento que no encajan en las anteriores |
revert |
Deshacer un commit anterior |
Si el cambio rompe la compatibilidad, lo marcas de una de dos formas: con un ! justo antes de los dos puntos (feat(api)!: remove the v1 endpoints) o con un pie BREAKING CHANGE: al final del mensaje que explique qué se rompe. Puedes usar las dos a la vez. Las herramientas lo interpretan como un cambio mayor de versión (la X de X.y.z en el versionado semántico). La especificación pide BREAKING CHANGE en mayúsculas para que sea inconfundible con texto normal y las herramientas lo detecten sin ambigüedad.
El ámbito es opcional y va entre paréntesis: indica qué parte del código toca el cambio, normalmente un módulo, un proyecto o una capa. En una solución .NET puede ser el nombre del proyecto o del área:
feat(orders): add endpoint to cancel orders
fix(auth): fix refresh token expiration
refactor(infrastructure)!: change the EF Core base repositorySi el cambio no encaja en un ámbito concreto, lo omites (docs: update the README). Conviene acordar en el equipo la lista de ámbitos, o al menos que sean nombres de proyecto, para que no aparezcan tres variantes del mismo.
Elegir el formato lleva cinco minutos. Conseguir que se cumpla en todos los clones del repo es más complicado.
Por qué falla el hook que solo vive en tu máquina
Yo lo intenté con un commit-msg en una carpeta que no era la de por defecto. Funcionaba en mi clon y en ningún otro, y si cada dev tiene que acordarse de activarlo, la regla acaba siendo opcional. Tres motivos:
.git/hooksno se versiona y no viaja con elgit clone.- Una carpeta propia, por ejemplo
.githooks, se puede versionar, pero Git solo la usa si cada dev ejecutagit config core.hooksPath .githooks. Esa config es local. - Aunque el hook esté activo,
git commit --no-verifylo salta, y algunos clientes gráficos ni lo ejecutan.
Capa 1: el hook viaja con el repo y se activa solo
Guarda el hook en el repo y haz que el build lo enchufe. Con Husky.Net queda así:
dotnet new tool-manifest
dotnet tool install Husky
dotnet husky install
dotnet husky add .husky/commit-msg -c 'dotnet husky run --name commit-msg --args "$1"'Para que nadie tenga que ejecutar nada, un target en src/Directory.Build.targets o en el proyecto que quieras prepara Husky en cada restore:
<Target Name="Husky" BeforeTargets="Restore;CollectPackageReferences"
Condition="'$(HUSKY)' != '0' And '$(CI)' != 'true' And '$(Configuration)' != 'Release' And Exists('$(MSBuildThisFileDirectory)../.git')">
<Exec Command="dotnet tool restore" StandardOutputImportance="Low" StandardErrorImportance="High" />
<Exec Command="dotnet husky install" WorkingDirectory="$(MSBuildThisFileDirectory).." StandardOutputImportance="Low" StandardErrorImportance="High" />
</Target>El target no se ejecuta en CI (variable CI), ni con -c Release, ni si defines HUSKY=0: los hooks solo tienen sentido en la máquina de quien hace commits, no en un build de release ni en un agente de CI. El dev clona, compila o restaura en Debug y core.hooksPath queda apuntando a .husky. Sin pasos extra en el README.
Capa 2: qué valida el hook
En .husky/task-runner.json puedes validar el mensaje con una expresión regular, sin depender de Node:
{
"tasks": [
{
"name": "commit-msg",
"command": "bash",
"args": [
"-c",
"grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\\([a-z0-9-]+\\))?!?: .{1,72}$' \"$1\" || { echo 'Use Conventional Commits: type(scope): description'; exit 1; }",
"--",
"${args}"
]
}
]
}Si ya usas Node en el repo, commitlint con @commitlint/config-conventional hace lo mismo con reglas más completas.
Capa 3: la red que no se salta
Lo anterior avisa pronto y en local, pero un hook se puede saltar. Un check obligatorio de la rama protegida no, así que la garantía tiene que estar en el servidor. Un workflow de un solo paso, sin acciones de terceros, puede validar el título del PR:
name: PR title
on:
pull_request:
types: [opened, edited, synchronize, reopened]
jobs:
title:
runs-on: ubuntu-latest
steps:
- name: Check Conventional Commits title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "$PR_TITLE" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .{1,72}$' \
|| { echo "::error::PR title must follow Conventional Commits: $PR_TITLE"; exit 1; }No hace falta actions/checkout, porque el título viaja en el evento del PR y no se lee ningún fichero del repo. El título pasa por una variable de entorno y no se interpola dentro del script, para que un título con comillas o $(...) no ejecute nada. La regex es la misma que la del hook: si cambias una, cambia la otra.
Después, en la protección de rama, marca este job como check obligatorio y activa Squash merge con “Default to pull request title”. Con squash, lo que queda en main es el título del PR. Los commits intermedios de la rama pueden ser un desastre, porque el historial de main seguirá cumpliendo el formato. Este check encaja junto al resto de comprobaciones del workflow de gobernanza .NET.
Si tu organización tiene GitHub Enterprise, hay otra vía sin workflow: un ruleset sobre main con la regla de metadatos de commit (“Restrict commit metadata” y “Must match a given regex pattern”), con la misma regex. Comprueba que tu plan la incluye antes de apoyarte en ella.