feat(orquestra): enforce semver and conventional commits

This commit is contained in:
Deploy
2026-08-25 22:03:50 +02:00
parent 66a08b61d2
commit 3917c8d6de
10 changed files with 483 additions and 19 deletions

55
docs/deployment.md Normal file
View File

@@ -0,0 +1,55 @@
# Deploy del monolito MercadoDeVida
## ¿Basta con copiar `project/`?
Sí, para levantar el monolito en un host basta con traer la carpeta `project/` completa y ejecutar:
```bash
cd project
./scripts/monolith.sh prod start
```
o, para reemplazar una versión anterior ya corriendo:
```bash
cd project
./scripts/monolith.sh prod restart
```
## Qué hace `monolith.sh prod start/restart`
1. Limpia procesos antiguos del proyecto y listeners en puertos gestionados.
2. Arranca PostgreSQL y Redis con Docker Compose.
3. Instala dependencias con `npm ci` en backend, admin, TPV, frontend y storefront.
4. Ejecuta migraciones.
5. Compila backend y frontends.
6. Arranca servicios con `nohup` y guarda PID/logs en `project/.runtime/prod/`.
7. Verifica HTTP para cada servicio.
Servicios por defecto:
| Servicio | Puerto |
|---|---:|
| Backend API | 3000 |
| Admin | 3001 |
| TPV | 3002 |
| Frontend | 3003 |
| Storefront SEO | 3004 |
## Requisitos del host
- Node.js >= 22
- npm
- Docker + Docker Compose
- Bash, curl, lsof
- Variables/secretos en `project/.env`
Si no existe `.env`, el script crea uno de desarrollo. En producción real conviene crear `.env` explícito antes de arrancar.
## Lo que NO gestiona
- Traefik/Nginx/Authelia o certificados TLS.
- Arranque automático tras reinicio del host.
- Backups de base de datos.
Para producción estable conviene envolver `./scripts/monolith.sh prod restart` con systemd, supervisor o el orquestador elegido.

83
docs/versioning.md Normal file
View File

@@ -0,0 +1,83 @@
# Versionado semántico y Conventional Commits
## Fuente de verdad
La versión del producto vive en:
```txt
project/VERSION
```
Los `package.json` del monolito deben mantenerse sincronizados con ese valor.
## Conventional Commits obligatorios
Formato:
```txt
<type>[optional scope][!]: <description>
```
Tipos permitidos:
- `feat`
- `fix`
- `docs`
- `chore`
- `refactor`
- `perf`
- `test`
- `build`
- `ci`
- `style`
- `revert`
Ejemplos:
```txt
feat(orders): add awaiting-payment notifications
fix(monolith): clean stale deployments before start
chore(release): v0.2.0
```
## Herramientas Orquestra
Validar un mensaje:
```bash
python3 scripts/validate_conventional_commit.py --message "fix(monolith): clean stale deployments"
```
Instalar hook local de Git:
```bash
./scripts/install_git_hooks.sh
```
Bump semver:
```bash
python3 scripts/version.py bump patch
python3 scripts/version.py bump minor --commit --tag
python3 scripts/version.py bump major --commit --tag
```
Ver versión actual:
```bash
python3 scripts/version.py show
```
## Cierre de features
`scripts/commit_feature.sh` genera commits convencionales automáticamente usando el tipo del ticket:
- `feature``feat(...)`
- `fix` / `bug``fix(...)`
- `chore``chore(...)`
`verify.sh` valida:
- `project/VERSION` como semver.
- `package.json` sincronizados.
- último commit compatible con Conventional Commits.

1
project/VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Commit and optionally push a completed feature
# Commit and optionally push a completed feature using Conventional Commits.
# Usage: ./scripts/commit_feature.sh <feature_id>
set -euo pipefail
@@ -16,7 +16,50 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
fi
# Stage all changes (excluding node_modules, dist, .next, etc.)
feature_meta() {
python3 - "$FEATURE_ID" <<'PY'
import json
import re
import sys
from pathlib import Path
fid = sys.argv[1]
path = Path('backlog/features.json')
commit_type = 'feat'
title = 'completed feature'
if path.exists():
data = json.loads(path.read_text(encoding='utf-8'))
for feature in data.get('features', []):
if str(feature.get('id')) == fid:
ftype = str(feature.get('type', 'feature')).lower()
commit_type = {
'feature': 'feat',
'fix': 'fix',
'bug': 'fix',
'chore': 'chore',
}.get(ftype, 'feat')
title = feature.get('title') or feature.get('description') or title
break
subject = re.sub(r'\s+', ' ', str(title)).strip()
subject = subject[0].lower() + subject[1:] if subject else 'completed feature'
subject = subject.rstrip('.')
print(commit_type)
print(subject[:120])
PY
}
mapfile -t META < <(feature_meta)
COMMIT_TYPE="${META[0]:-feat}"
COMMIT_SUBJECT="${META[1]:-completed feature}"
SCOPE=$(printf '%s' "$FEATURE_ID" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._/-' '-')
COMMIT_MSG="$COMMIT_TYPE($SCOPE): $COMMIT_SUBJECT"
# Validate before touching git state.
python3 scripts/validate_conventional_commit.py --message "$COMMIT_MSG"
# Stage all changes (excluding node_modules, dist, .next, etc. via .gitignore)
git add -A
# Check if there are changes to commit
@@ -25,24 +68,10 @@ if git diff --cached --quiet; then
exit 0
fi
# Create commit message
COMMIT_MSG="feat($FEATURE_ID): completed feature"
# Check if there's a description in the feature
if [ -f "work/current.md" ]; then
# Extract first line of description if available
DESC=$(grep -m1 "^## Description" work/current.md -A1 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//' || true)
if [ -n "$DESC" ]; then
COMMIT_MSG="$COMMIT_MSG
$DESC"
fi
fi
# Commit
git commit -m "$COMMIT_MSG"
echo "[OK] Committed $FEATURE_ID"
echo "[OK] Committed $FEATURE_ID: $COMMIT_MSG"
# Check if remote exists and push
if git remote get-url origin >/dev/null 2>&1; then

3
scripts/git-hooks/commit-msg Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
python3 scripts/validate_conventional_commit.py --file "$1"

View File

@@ -149,9 +149,10 @@ copy_dir_update "$SOURCE_DIR/harness" "$TARGET_DIR/harness"
copy_dir_update "$SOURCE_DIR/platforms/pi" "$TARGET_DIR/platforms/pi"
copy_dir_update "$SOURCE_DIR/docs" "$TARGET_DIR/docs"
for file in scripts/verify.sh scripts/agent_status.py scripts/new_ticket.py scripts/pi_orquestra.sh scripts/run_stage.py scripts/commit_feature.sh scripts/close_feature.py scripts/install.sh; do
for file in scripts/verify.sh scripts/agent_status.py scripts/new_ticket.py scripts/pi_orquestra.sh scripts/run_stage.py scripts/commit_feature.sh scripts/close_feature.py scripts/validate_conventional_commit.py scripts/version.py scripts/install_git_hooks.sh scripts/install.sh; do
copy_file_update "$SOURCE_DIR/$file" "$TARGET_DIR/$file"
done
copy_dir_update "$SOURCE_DIR/scripts/git-hooks" "$TARGET_DIR/scripts/git-hooks"
append_gitignore_block "$TARGET_DIR/.gitignore"
@@ -176,7 +177,7 @@ if [ -d "$TARGET_DIR/.pi/subagents" ] || [ -f "$TARGET_DIR/.pi/subagents.json" ]
warn "Found .pi/subagents or .pi/subagents.json in target. Orquestra does not delete user Pi config automatically; remove Orquestra-owned stale subagent files manually before running verify.sh."
fi
chmod +x "$TARGET_DIR/scripts/verify.sh" "$TARGET_DIR/scripts/pi_orquestra.sh" "$TARGET_DIR/scripts/run_stage.py" "$TARGET_DIR/scripts/install.sh"
chmod +x "$TARGET_DIR/scripts/verify.sh" "$TARGET_DIR/scripts/pi_orquestra.sh" "$TARGET_DIR/scripts/run_stage.py" "$TARGET_DIR/scripts/install.sh" "$TARGET_DIR/scripts/validate_conventional_commit.py" "$TARGET_DIR/scripts/version.py" "$TARGET_DIR/scripts/install_git_hooks.sh" "$TARGET_DIR/scripts/git-hooks/commit-msg"
cat <<EOF
@@ -184,6 +185,7 @@ Orquestra installed/updated safely in: $TARGET_DIR
Next steps:
cd "$TARGET_DIR"
./scripts/install_git_hooks.sh
./scripts/verify.sh
./scripts/pi_orquestra.sh
EOF

16
scripts/install_git_hooks.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
cd "$ROOT"
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo '[FAIL] Not inside a git repository' >&2
exit 1
}
chmod +x scripts/git-hooks/commit-msg scripts/validate_conventional_commit.py scripts/version.py
git config core.hooksPath scripts/git-hooks
echo '[OK] Git hooks installed: core.hooksPath=scripts/git-hooks'
echo '[OK] commit-msg now enforces Conventional Commits'

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Validate Conventional Commit messages for Orquestra.
Allowed format:
<type>[optional scope][!]: <description>
Examples:
feat(orders): add awaiting-payment notifications
fix(DEPLOY-MONOLITH-CLEANUP): clean stale deployments
chore(release)!: v1.0.0
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ALLOWED_TYPES = {
'build',
'chore',
'ci',
'docs',
'feat',
'fix',
'perf',
'refactor',
'revert',
'style',
'test',
}
HEADER_RE = re.compile(
r'^(?P<type>build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)'
r'(?:\((?P<scope>[A-Za-z0-9._/-]+)\))?'
r'(?P<breaking>!)?: (?P<description>\S.{0,119})$'
)
def validate(message: str) -> tuple[bool, str | None]:
lines = message.strip('\n').splitlines()
if not lines or not lines[0].strip():
return False, 'empty commit message'
header = lines[0].strip()
match = HEADER_RE.match(header)
if not match:
return (
False,
"first line must be Conventional Commit: "
"<type>[optional scope][!]: <description>",
)
commit_type = match.group('type')
if commit_type not in ALLOWED_TYPES:
return False, f"unsupported type '{commit_type}'"
description = match.group('description').strip()
if description.endswith('.'):
return False, 'description must not end with a period'
if any(line.rstrip() != line for line in lines):
return False, 'commit message contains trailing whitespace'
if len(lines) > 1 and lines[1].strip():
return False, 'second line must be blank before body/footer'
return True, None
def main() -> int:
parser = argparse.ArgumentParser(description='Validate Conventional Commit message')
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--file', type=Path, help='commit message file')
group.add_argument('--message', help='commit message string')
args = parser.parse_args()
message = args.message if args.message is not None else args.file.read_text(encoding='utf-8')
ok, error = validate(message)
if ok:
return 0
print(f'[FAIL] Invalid commit message: {error}', file=sys.stderr)
print('', file=sys.stderr)
print('Examples:', file=sys.stderr)
print(' feat(orders): add awaiting-payment notifications', file=sys.stderr)
print(' fix(monolith): clean stale deployments before start', file=sys.stderr)
print(' chore(release): v0.2.0', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())

View File

@@ -39,6 +39,11 @@ required=(
"scripts/run_stage.py"
"scripts/commit_feature.sh"
"scripts/close_feature.py"
"scripts/validate_conventional_commit.py"
"scripts/version.py"
"scripts/install_git_hooks.sh"
"scripts/git-hooks/commit-msg"
"project/VERSION"
"platforms/pi/README.md"
"platforms/pi/extensions/orquestra-status/index.ts"
"platforms/pi/extensions/orquestra-web-fetch.ts"
@@ -84,6 +89,55 @@ else
ok "Sin archivos de producto/código en la raíz"
fi
python3 - <<'PY'
import json
import pathlib
import re
import subprocess
import sys
root = pathlib.Path('.')
version_path = root / 'project' / 'VERSION'
semver = re.compile(r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$')
try:
version = version_path.read_text(encoding='utf-8').strip()
except Exception as e:
print(f'[FAIL] project/VERSION no se puede leer: {e}')
sys.exit(1)
if not semver.match(version):
print(f'[FAIL] project/VERSION no es semver válido: {version}')
sys.exit(1)
for rel in [
'project/package.json',
'project/apps/admin/package.json',
'project/apps/pos/package.json',
'project/frontend/package.json',
'project/storefront/package.json',
]:
path = root / rel
if not path.exists():
continue
data = json.loads(path.read_text(encoding='utf-8'))
if data.get('version') != version:
print(f"[FAIL] {rel} version {data.get('version')} != project/VERSION {version}")
sys.exit(1)
try:
msg = subprocess.check_output(['git', 'log', '-1', '--pretty=%B'], text=True).strip()
except Exception:
msg = ''
if msg:
result = subprocess.run(['python3', 'scripts/validate_conventional_commit.py', '--message', msg])
if result.returncode != 0:
print('[FAIL] Último commit no cumple Conventional Commits')
sys.exit(1)
print(f'[OK] semver válido y sincronizado: {version}')
print('[OK] último commit cumple Conventional Commits')
PY
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
echo ""
echo "── 2) Validando requisitos Pi ─────────────────────────"
if command -v pi >/dev/null 2>&1; then

128
scripts/version.py Executable file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Semantic version helper for MercadoDeVida.
Source of truth: project/VERSION.
Mirrors the version into package.json files that are part of project/.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VERSION_FILE = ROOT / 'project' / 'VERSION'
PACKAGE_FILES = [
ROOT / 'project' / 'package.json',
ROOT / 'project' / 'apps' / 'admin' / 'package.json',
ROOT / 'project' / 'apps' / 'pos' / 'package.json',
ROOT / 'project' / 'frontend' / 'package.json',
ROOT / 'project' / 'storefront' / 'package.json',
]
PACKAGE_LOCK_FILES = [
ROOT / 'project' / 'package-lock.json',
ROOT / 'project' / 'apps' / 'admin' / 'package-lock.json',
ROOT / 'project' / 'apps' / 'pos' / 'package-lock.json',
ROOT / 'project' / 'frontend' / 'package-lock.json',
ROOT / 'project' / 'storefront' / 'package-lock.json',
]
SEMVER_RE = re.compile(r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$')
def read_version() -> str:
version = VERSION_FILE.read_text(encoding='utf-8').strip()
if not SEMVER_RE.match(version):
raise SystemExit(f'[FAIL] Invalid semver in {VERSION_FILE}: {version}')
return version
def write_version(version: str) -> None:
if not SEMVER_RE.match(version):
raise SystemExit(f'[FAIL] Invalid semver: {version}')
VERSION_FILE.write_text(version + '\n', encoding='utf-8')
for path in PACKAGE_FILES:
if not path.exists():
continue
data = json.loads(path.read_text(encoding='utf-8'))
data['version'] = version
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
for path in PACKAGE_LOCK_FILES:
if not path.exists():
continue
data = json.loads(path.read_text(encoding='utf-8'))
if 'version' in data:
data['version'] = version
root_package = data.get('packages', {}).get('')
if isinstance(root_package, dict) and 'version' in root_package:
root_package['version'] = version
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def bump(version: str, part: str) -> str:
major, minor, patch = [int(p) for p in version.split('-', 1)[0].split('+', 1)[0].split('.')]
if part == 'major':
return f'{major + 1}.0.0'
if part == 'minor':
return f'{major}.{minor + 1}.0'
if part == 'patch':
return f'{major}.{minor}.{patch + 1}'
raise SystemExit(f'[FAIL] Unsupported bump part: {part}')
def run(cmd: list[str]) -> None:
subprocess.run(cmd, cwd=ROOT, check=True)
def main() -> int:
parser = argparse.ArgumentParser(description='Manage project semantic version')
sub = parser.add_subparsers(dest='command', required=True)
sub.add_parser('show', help='print current version')
bump_parser = sub.add_parser('bump', help='bump semantic version')
bump_parser.add_argument('part', choices=['major', 'minor', 'patch'])
bump_parser.add_argument('--commit', action='store_true', help='create conventional release commit')
bump_parser.add_argument('--tag', action='store_true', help='create git tag vX.Y.Z (requires --commit or clean index)')
set_parser = sub.add_parser('set', help='set exact semantic version')
set_parser.add_argument('version')
args = parser.parse_args()
current = read_version()
if args.command == 'show':
print(current)
return 0
if args.command == 'set':
write_version(args.version)
print(f'[OK] Version set: {current} -> {args.version}')
return 0
next_version = bump(current, args.part)
write_version(next_version)
print(f'[OK] Version bumped: {current} -> {next_version}')
if args.commit:
msg = f'chore(release): v{next_version}'
run(['python3', 'scripts/validate_conventional_commit.py', '--message', msg])
run([
'git',
'add',
'project/VERSION',
*[str(p.relative_to(ROOT)) for p in PACKAGE_FILES if p.exists()],
*[str(p.relative_to(ROOT)) for p in PACKAGE_LOCK_FILES if p.exists()],
])
run(['git', 'commit', '-m', msg])
if args.tag:
run(['git', 'tag', f'v{next_version}'])
print(f'[OK] Tagged v{next_version}')
return 0
if __name__ == '__main__':
raise SystemExit(main())