@dforce2055/dai 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.dai.example +16 -0
- package/CHANGELOG.md +71 -0
- package/README.md +3 -2
- package/VERSION +1 -1
- package/cli/dai.mjs +545 -2
- package/cli/lib/branch-scope.mjs +24 -9
- package/cli/lib/help.mjs +76 -0
- package/cli/lib/notify.mjs +205 -0
- package/cli/lib/pm-adapter.mjs +16 -0
- package/cli/lib/pm-clickup.mjs +17 -0
- package/cli/lib/pm-jira.mjs +20 -0
- package/cli/lib/pr-remote.mjs +14 -0
- package/cli/lib/release-files.mjs +119 -0
- package/cli/lib/release-plan.mjs +221 -0
- package/cli/lib/release-stamp.mjs +87 -0
- package/docs/adr/0019-ciclo-de-version-y-aviso-de-release.md +190 -0
- package/docs/adr/README.md +1 -0
- package/docs/guias/index.md +3 -0
- package/docs/guias/releases.md +156 -0
- package/docs/tutoriales/ciclo-de-release.md +266 -0
- package/docs/tutoriales/index.md +6 -0
- package/package.json +1 -1
- package/skills/dai-release/SKILL.md +167 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// dai · el manifiesto de una release: qué entra entre el último tag y la integración.
|
|
2
|
+
//
|
|
3
|
+
// Es la pieza de la que dependen los otros comandos de `dai release`, y la que contesta la
|
|
4
|
+
// pregunta que a un equipo sin versiones no le contesta nadie: **¿qué User Stories tiene
|
|
5
|
+
// esta versión?** El resto —el tag, el CHANGELOG, el comentario en cada ticket, el aviso al
|
|
6
|
+
// canal— son formas distintas de publicar ESTE dato.
|
|
7
|
+
//
|
|
8
|
+
// Todo acá es puro: entran las salidas de git ya crudas y sale la decisión. Los efectos
|
|
9
|
+
// (correr git, consultar el tracker) viven en dai.mjs.
|
|
10
|
+
|
|
11
|
+
import { parseVersion } from "./semver.mjs";
|
|
12
|
+
import { branchType, trackerKeysIn } from "./branch-scope.mjs";
|
|
13
|
+
|
|
14
|
+
// ── Commits ──────────────────────────────────────────────────────────────────
|
|
15
|
+
// Formato pedido a git: `%H%x1f%s` por línea (sha, US, subject) — el separador es \x1f
|
|
16
|
+
// (unit separator) y no un pipe o un tab porque un subject puede contener cualquiera de
|
|
17
|
+
// esos, y partir mal el log es empezar el manifiesto con datos corridos.
|
|
18
|
+
export function parseCommitLog(raw) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const line of String(raw ?? "").split("\n")) {
|
|
21
|
+
if (!line.trim()) continue;
|
|
22
|
+
const i = line.indexOf("\x1f");
|
|
23
|
+
if (i === -1) continue;
|
|
24
|
+
const sha = line.slice(0, i).trim();
|
|
25
|
+
const subject = line.slice(i + 1);
|
|
26
|
+
out.push({ sha, subject, ...classifySubject(subject) });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Conventional Commits, con la tolerancia justa: el repo puede tener commits que no lo
|
|
32
|
+
// sigan, y un manifiesto que los ignora miente por omisión. Los que no matchean quedan
|
|
33
|
+
// con type null y se cuentan igual.
|
|
34
|
+
const CC = /^(?<type>[a-z]+)(?:\((?<scope>[^)]*)\))?(?<bang>!)?:\s*(?<rest>.+)$/i;
|
|
35
|
+
|
|
36
|
+
export function classifySubject(subject) {
|
|
37
|
+
const s = String(subject ?? "");
|
|
38
|
+
const merge = /^Merge (pull request|branch|remote-tracking)/i.test(s);
|
|
39
|
+
const m = s.match(CC);
|
|
40
|
+
if (!m) return { type: null, scope: null, breaking: false, merge };
|
|
41
|
+
return {
|
|
42
|
+
type: m.groups.type.toLowerCase(),
|
|
43
|
+
scope: m.groups.scope || null,
|
|
44
|
+
breaking: Boolean(m.groups.bang),
|
|
45
|
+
merge,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// La branch que trajo un merge commit, cuando el forge la nombra en el subject:
|
|
50
|
+
// "Merge pull request #47 from dforce2055/fix/pr-base" → "fix/pr-base"
|
|
51
|
+
// "Merge branch 'feature/ABC-1-x' into develop" → "feature/ABC-1-x"
|
|
52
|
+
// Sirve para detectar trabajo que ENTRÓ sin declarar US. Si el equipo hace squash o
|
|
53
|
+
// rebase no hay merge commits y esta señal no existe: por eso es un aviso, no un gate.
|
|
54
|
+
export function mergedBranch(subject) {
|
|
55
|
+
const s = String(subject ?? "");
|
|
56
|
+
const pr = s.match(/^Merge pull request #\d+ from [^/\s]+\/(.+?)\s*$/i);
|
|
57
|
+
if (pr) return pr[1];
|
|
58
|
+
const br = s.match(/^Merge (?:remote-tracking )?branch '([^']+)'/i);
|
|
59
|
+
if (br) return br[1].replace(/^origin\//, "");
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Bump propuesto ───────────────────────────────────────────────────────────
|
|
64
|
+
// PROPUESTO, no decidido. dai mira los tipos de commit, que es lo único que puede leer
|
|
65
|
+
// sin criterio; la regla del repo mira el COMPORTAMIENTO, y esa diferencia no es teórica:
|
|
66
|
+
// la 0.14.0 salió con cuatro commits `fix:` y era minor, porque cambió un default
|
|
67
|
+
// observable. Cualquier derivación automática habría cortado un patch equivocado.
|
|
68
|
+
// Por eso esto devuelve un PISO y su justificación, y quien firma es una persona.
|
|
69
|
+
export function proposeBump(commits = []) {
|
|
70
|
+
const reales = commits.filter((c) => !c.merge);
|
|
71
|
+
if (reales.some((c) => c.breaking)) {
|
|
72
|
+
const cual = reales.find((c) => c.breaking);
|
|
73
|
+
return { bump: "major", floor: true, reason: `hay un commit marcado como breaking (${cual.subject})` };
|
|
74
|
+
}
|
|
75
|
+
if (reales.some((c) => c.type === "feat")) {
|
|
76
|
+
const n = reales.filter((c) => c.type === "feat").length;
|
|
77
|
+
return { bump: "minor", floor: true, reason: `${n} commit(s) feat: agregan funcionalidad` };
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
bump: "patch",
|
|
81
|
+
floor: true,
|
|
82
|
+
reason: reales.length
|
|
83
|
+
? `solo hay ${[...new Set(reales.map((c) => c.type || "sin-tipo"))].sort().join(", ")}: ningún feat ni breaking`
|
|
84
|
+
: "no hay commits nuevos",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// El aviso que acompaña SIEMPRE a la propuesta. No es decoración: es la diferencia entre
|
|
89
|
+
// una sugerencia y una automatización que se equivoca en silencio.
|
|
90
|
+
export const BUMP_CAVEAT =
|
|
91
|
+
"propuesta a partir de los TIPOS de commit — la regla del repo mira el COMPORTAMIENTO.\n" +
|
|
92
|
+
" Si algo mueve un default, agrega un flag o cambia lo que ve quien no configura nada,\n" +
|
|
93
|
+
" es minor aunque todo sea `fix:`. Decidilo vos: dai propone, no versiona por su cuenta.";
|
|
94
|
+
|
|
95
|
+
export function nextVersion(current, bump) {
|
|
96
|
+
const v = parseVersion(current);
|
|
97
|
+
if (!v) return null;
|
|
98
|
+
if (bump === "major") return `${v.major + 1}.0.0`;
|
|
99
|
+
if (bump === "minor") return `${v.major}.${v.minor + 1}.0`;
|
|
100
|
+
return `${v.major}.${v.minor}.${v.patch + 1}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Manifiesto ───────────────────────────────────────────────────────────────
|
|
104
|
+
// `linked` son las filas de implements.yaml que aparecieron en el rango (las arma dai.mjs
|
|
105
|
+
// leyendo el árbol de cada commit: el link viaja CON el código, así que no depende de que
|
|
106
|
+
// la branch siga existiendo ni de que el change no se haya archivado).
|
|
107
|
+
// `live` es lo que contestó el tracker por id: { [id]: { title, ac_hash, spec_version } }.
|
|
108
|
+
export function buildManifest({ commits = [], linked = [], live = {}, unreachable = false, repoUsesStories = true } = {}) {
|
|
109
|
+
// Una US puede aparecer en varios commits (se creó el link, después se resincronizó) y
|
|
110
|
+
// en dos paths (el change y su copia archivada). El manifiesto la nombra UNA vez.
|
|
111
|
+
const porId = new Map();
|
|
112
|
+
for (const r of linked) {
|
|
113
|
+
if (!r?.id) continue;
|
|
114
|
+
const prev = porId.get(r.id);
|
|
115
|
+
// Gana la última aparición: es el estado con el que la US entró al release.
|
|
116
|
+
if (!prev || (r.order ?? 0) >= (prev.order ?? 0)) porId.set(r.id, r);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stories = [...porId.values()].map((r) => {
|
|
120
|
+
const l = live[r.id];
|
|
121
|
+
const estado = unreachable || !l ? (unreachable ? "sin-respuesta" : "sin-us")
|
|
122
|
+
: l.ac_hash === r.ac_hash ? "al-dia" : "atrasado";
|
|
123
|
+
return {
|
|
124
|
+
id: r.id,
|
|
125
|
+
title: l?.title ?? null,
|
|
126
|
+
version: r.version ?? null,
|
|
127
|
+
ac_hash: r.ac_hash ?? null,
|
|
128
|
+
spec_version: l?.spec_version ?? null,
|
|
129
|
+
change: r.change ?? null,
|
|
130
|
+
status: estado,
|
|
131
|
+
};
|
|
132
|
+
}).sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
133
|
+
|
|
134
|
+
// Las branches que se mergearon en el rango, separadas en las que declaran US y las que
|
|
135
|
+
// están exentas por tipo. Las que NO son ninguna de las dos son el hallazgo: entró
|
|
136
|
+
// trabajo de producto sin link, y esta es la última oportunidad de verlo.
|
|
137
|
+
const ids = new Set(stories.map((s) => String(s.id).toLowerCase()));
|
|
138
|
+
const branches = commits.map((c) => mergedBranch(c.subject)).filter(Boolean);
|
|
139
|
+
const EXENTAS = new Set(["chore", "docs", "ci", "build", "test", "refactor", "style", "release", "hotfix", "revert"]);
|
|
140
|
+
const chores = [], orphans = [];
|
|
141
|
+
for (const b of branches) {
|
|
142
|
+
if (nombraAlguna(b, ids)) continue; // ya está contada como US
|
|
143
|
+
if (EXENTAS.has(branchType(b))) { chores.push(b); continue; }
|
|
144
|
+
// "Entró trabajo sin link" solo es un hallazgo si el repo trabaja con User Stories. En
|
|
145
|
+
// un repo de tooling —el de dai, sin ir más lejos— NINGUNA branch va a declarar una, y
|
|
146
|
+
// marcarlas todas convierte el aviso en ruido que se aprende a ignorar. La excepción es
|
|
147
|
+
// una branch que NOMBRA un ticket: ahí alguien quiso linkear una US y no lo hizo, y eso
|
|
148
|
+
// vale como hallazgo aunque el repo no declare ninguna.
|
|
149
|
+
if (repoUsesStories || trackerKeysIn(b).length > 0) orphans.push(b);
|
|
150
|
+
else chores.push(b);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
stories,
|
|
155
|
+
chores,
|
|
156
|
+
orphans,
|
|
157
|
+
counts: {
|
|
158
|
+
commits: commits.filter((c) => !c.merge).length,
|
|
159
|
+
merges: commits.filter((c) => c.merge).length,
|
|
160
|
+
stories: stories.length,
|
|
161
|
+
atrasadas: stories.filter((s) => s.status === "atrasado").length,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ¿El nombre de la branch nombra alguna de las US del manifiesto? Comparación en
|
|
167
|
+
// minúsculas: el slug de la branch va en minúscula y el key del tracker en mayúscula.
|
|
168
|
+
function nombraAlguna(branch, ids) {
|
|
169
|
+
const b = String(branch).toLowerCase();
|
|
170
|
+
for (const id of ids) if (b.includes(id)) return true;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
175
|
+
const ICONO = { "al-dia": "✅", atrasado: "⚠️ ", "sin-us": "❓", "sin-respuesta": "⚠️ " };
|
|
176
|
+
const ETIQUETA = {
|
|
177
|
+
"al-dia": "al día", atrasado: "ATRASADA", "sin-us": "no está en el tracker",
|
|
178
|
+
"sin-respuesta": "no verificada",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export function renderManifest(m, ctx = {}) {
|
|
182
|
+
const { from, to, current, proposed, bump, reason } = ctx;
|
|
183
|
+
const L = [];
|
|
184
|
+
L.push(` ── Release a preparar ────────────────────────────────`);
|
|
185
|
+
L.push(` desde: ${from || "(el principio del repo)"}`);
|
|
186
|
+
L.push(` hasta: ${to}`);
|
|
187
|
+
L.push(` cambios: ${m.counts.commits} commit(s) · ${m.counts.merges} merge(s)`);
|
|
188
|
+
L.push(` versión: ${current} → ${proposed || "(sin propuesta)"} (${bump})`);
|
|
189
|
+
L.push(` ─────────────────────────────────────────────────────`);
|
|
190
|
+
|
|
191
|
+
if (m.stories.length === 0) {
|
|
192
|
+
L.push(` Ninguna US declarada en este rango.`);
|
|
193
|
+
} else {
|
|
194
|
+
L.push(` User Stories que entran (${m.stories.length}):`);
|
|
195
|
+
const w = Math.max(...m.stories.map((s) => String(s.id).length), 4);
|
|
196
|
+
for (const s of m.stories) {
|
|
197
|
+
const t = s.title ? ` ${s.title}` : "";
|
|
198
|
+
L.push(` ${ICONO[s.status] || " "} ${String(s.id).padEnd(w)} ${s.version || "?"}${t}`);
|
|
199
|
+
if (s.status !== "al-dia") L.push(` ${" ".repeat(w)} ${ETIQUETA[s.status]}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (m.counts.atrasadas > 0) {
|
|
203
|
+
L.push(``);
|
|
204
|
+
L.push(` ⚠ ${m.counts.atrasadas} US ATRASADA(S): el QUÉ cambió después de implementarlo.`);
|
|
205
|
+
L.push(` Esta release las llevaría sin cubrir el criterio nuevo. Revisalas antes de cortar.`);
|
|
206
|
+
}
|
|
207
|
+
if (m.chores.length) {
|
|
208
|
+
L.push(``);
|
|
209
|
+
L.push(` Sin US (${m.chores.length}): ${m.chores.slice(0, 6).join(", ")}${m.chores.length > 6 ? "…" : ""}`);
|
|
210
|
+
}
|
|
211
|
+
if (m.orphans.length) {
|
|
212
|
+
L.push(``);
|
|
213
|
+
L.push(` ⚠ ${m.orphans.length} branch(es) sin US y sin prefijo exento:`);
|
|
214
|
+
for (const b of m.orphans) L.push(` ${b}`);
|
|
215
|
+
L.push(` Entró trabajo que nadie va a poder rastrear a una historia. Es la última`);
|
|
216
|
+
L.push(` oportunidad de verlo antes de que quede adentro de una versión.`);
|
|
217
|
+
}
|
|
218
|
+
L.push(` ─────────────────────────────────────────────────────`);
|
|
219
|
+
if (reason) L.push(` ${bump}: ${reason}`);
|
|
220
|
+
return L.join("\n");
|
|
221
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// dai · el comentario que le avisa a cada User Story en qué versión y en qué ambiente salió.
|
|
2
|
+
//
|
|
3
|
+
// Es la vuelta completa de la trazabilidad: el `implements.yaml` ata la US al código, el
|
|
4
|
+
// `dai stamp` de cobertura ata la US al commit, y esto ata la US a la VERSIÓN DESPLEGADA.
|
|
5
|
+
// Con eso, el funcional abre el ticket y ve "esto está en producción desde la v1.2.0" sin
|
|
6
|
+
// preguntarle a nadie — que es exactamente lo que un equipo sin versiones no puede contestar.
|
|
7
|
+
//
|
|
8
|
+
// Dos cuidados que mandan sobre el diseño de este módulo:
|
|
9
|
+
//
|
|
10
|
+
// 1. Escribe N veces hacia afuera, en tickets de gente distinta, y no se deshace. Por eso
|
|
11
|
+
// el comando muestra el alcance ANTES (cuántos comentarios, en qué tickets) y pide
|
|
12
|
+
// confirmación. Un susto genérico no sirve: el número tiene que ser el real.
|
|
13
|
+
// 2. Es OPCIONAL. Si alguien no quiere hacer ruido en veinte tickets, la release ya está
|
|
14
|
+
// hecha: el tag existe, el release note existe. Decir que no no puede romper nada.
|
|
15
|
+
|
|
16
|
+
// La marca que hace el comentario reconocible para dai mismo. Sin esto no hay forma de
|
|
17
|
+
// saber si ya estampamos: redesplegar la misma versión llenaría el ticket de comentarios
|
|
18
|
+
// idénticos, y un ticket con cuarenta avisos de deploy no lo lee nadie.
|
|
19
|
+
export function releaseMarker({ app, version, environment }) {
|
|
20
|
+
const v = String(version ?? "").replace(/^v/, "");
|
|
21
|
+
return `[dai:release app=${app || "?"} version=${v} env=${String(environment ?? "").toLowerCase() || "?"}]`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ¿Este comentario ya está puesto? Se compara la marca completa, así que la misma versión
|
|
25
|
+
// en OTRO ambiente (o de otra app) no se confunde con una repetición.
|
|
26
|
+
export function alreadyStamped(comments = [], marker) {
|
|
27
|
+
return comments.some((c) => String(c ?? "").includes(marker));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// El comentario, en markdown. Los backends lo convierten a lo suyo (Jira lo pasa a ADF,
|
|
31
|
+
// ClickUp lo manda como texto). Corto a propósito: el detalle vive en el release note,
|
|
32
|
+
// y el ticket solo necesita saber qué salió, dónde y cuándo.
|
|
33
|
+
export function renderReleaseStamp(ev = {}) {
|
|
34
|
+
const v = `v${String(ev.version ?? "").replace(/^v/, "")}`;
|
|
35
|
+
const amb = String(ev.environment ?? "").toUpperCase();
|
|
36
|
+
const L = [`**${ev.app || "app"} ${v}** desplegada en **${amb}** — ${ev.date || ""}`.trim(), ""];
|
|
37
|
+
if (ev.commit) L.push(`- commit: \`${String(ev.commit).slice(0, 8)}\``);
|
|
38
|
+
if (ev.url) L.push(`- release: ${ev.url}`);
|
|
39
|
+
L.push("");
|
|
40
|
+
L.push(releaseMarker(ev));
|
|
41
|
+
return L.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// El plan del estampado: a quién le toca, a quién no, y por qué. Se calcula ANTES de
|
|
45
|
+
// escribir nada para poder mostrarlo — mostrar "12 US" cuando en realidad se van a escribir
|
|
46
|
+
// 9 comentarios es la clase de aviso que la gente aprende a ignorar.
|
|
47
|
+
//
|
|
48
|
+
// stories → las US del manifiesto
|
|
49
|
+
// stamped → Set de ids que YA tienen la marca de este (app, versión, ambiente)
|
|
50
|
+
export function stampPlan({ stories = [], stamped = new Set(), unknown = false } = {}) {
|
|
51
|
+
const pendientes = [], repetidas = [];
|
|
52
|
+
for (const s of stories) (stamped.has(s.id) ? repetidas : pendientes).push(s);
|
|
53
|
+
return { pendientes, repetidas, unknown, total: stories.length };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// El aviso de alcance. Es la pantalla que pidió existir: cuántos comentarios, en qué
|
|
57
|
+
// tickets, y que no se deshace.
|
|
58
|
+
export function renderStampPlan(plan, { app, version, environment, tracker } = {}) {
|
|
59
|
+
const v = `v${String(version ?? "").replace(/^v/, "")}`;
|
|
60
|
+
const L = [];
|
|
61
|
+
L.push(` ── Estampar despliegue ───────────────────────────────`);
|
|
62
|
+
L.push(` versión: ${v} app: ${app || "?"} ambiente: ${String(environment ?? "").toUpperCase()}`);
|
|
63
|
+
L.push(` tracker: ${tracker || "?"} · ${plan.total} User Storie(s) en el release`);
|
|
64
|
+
L.push(` ─────────────────────────────────────────────────────`);
|
|
65
|
+
for (const s of plan.pendientes) L.push(` ${s.id}${s.title ? ` ${s.title}` : ""}`);
|
|
66
|
+
for (const s of plan.repetidas) L.push(` ${s.id}${s.title ? ` ${s.title}` : ""} ← ya estampada, se saltea`);
|
|
67
|
+
if (plan.total === 0) L.push(` (ninguna: el manifiesto de esta versión no declara US)`);
|
|
68
|
+
L.push(` ─────────────────────────────────────────────────────`);
|
|
69
|
+
if (plan.unknown) {
|
|
70
|
+
L.push(` ⚠ No pude leer los comentarios del tracker, así que no sé cuáles ya estampé.`);
|
|
71
|
+
L.push(` Si esta versión ya se estampó en este ambiente, van a salir repetidos.`);
|
|
72
|
+
}
|
|
73
|
+
return L.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// La frase que decide. Dice el número REAL de escrituras y que no hay vuelta atrás.
|
|
77
|
+
export function stampWarning(plan) {
|
|
78
|
+
const n = plan.pendientes.length;
|
|
79
|
+
if (n === 0) return "no hay nada para estampar: todas las US ya tienen esta versión en este ambiente.";
|
|
80
|
+
const saltea = plan.repetidas.length ? ` (${plan.repetidas.length} ya estampada(s), se saltean)` : "";
|
|
81
|
+
return `esto escribe ${n} comentario(s) en el tracker de todo el equipo${saltea}. No se deshace.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Lo que se pierde al decir que no. Se dice UNA vez, sin insistir: elegir menos ruido es
|
|
85
|
+
// una decisión legítima, no un error a corregir.
|
|
86
|
+
export const SIN_ESTAMPAR =
|
|
87
|
+
"sin estampar, el ticket no va a decir en qué versión salió: el registro queda solo en el release note.";
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# ADR-0019 — El ciclo de versión: manifiesto de US, dos mitades con una firma en el medio, y el aviso como evento
|
|
2
|
+
|
|
3
|
+
- **Estado:** aceptado
|
|
4
|
+
- **Fecha:** 2026-09-09
|
|
5
|
+
- **Decide:** lead / arquitecto de la metodología
|
|
6
|
+
|
|
7
|
+
## Contexto
|
|
8
|
+
|
|
9
|
+
Hay equipos —grandes y medianos, no solo los desprolijos— que **no arman ramas de release
|
|
10
|
+
ni etiquetan versiones**. Desplegar a producción se vuelve un ejercicio de memoria: hay que
|
|
11
|
+
acordarse de qué ramas componen la funcionalidad y mergearlas a la rama de producción una
|
|
12
|
+
por una, en el momento del despliegue.
|
|
13
|
+
|
|
14
|
+
Eso tiene tres consecuencias, y las tres se ven en repos reales:
|
|
15
|
+
|
|
16
|
+
1. **Se despliega una combinación que nunca se probó.** Cada orden de merge produce un
|
|
17
|
+
árbol distinto; el que llega a producción no existió en ningún ambiente antes.
|
|
18
|
+
2. **No se puede contestar qué hay en cada ambiente.** Ni qué funcionalidades comprende.
|
|
19
|
+
Cuando algo falla a las tres de la mañana, no hay a qué volver.
|
|
20
|
+
3. **Se cuelan funcionalidades que no estaban listas**, porque la selección de qué va se
|
|
21
|
+
hace rama por rama y a mano.
|
|
22
|
+
|
|
23
|
+
dai ya sabía atar una User Story a su código (`implements.yaml`) y a su commit
|
|
24
|
+
(`dai stamp`). Lo que faltaba era el último eslabón: atar la US a la **versión desplegada**.
|
|
25
|
+
Sin eso, la trazabilidad llega hasta la PR y se corta justo donde el negocio pregunta.
|
|
26
|
+
|
|
27
|
+
## Decisión
|
|
28
|
+
|
|
29
|
+
### 1. dai aporta el manifiesto, no la estrategia de branching
|
|
30
|
+
|
|
31
|
+
La estrategia de ramas la elige cada equipo, y dai no opina: es una herramienta, no un
|
|
32
|
+
mandato. Lo que dai aporta es el eje que ya es suyo, extendido un paso:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
implements.yaml → commit → PR → VERSIÓN → AMBIENTE
|
|
36
|
+
(ADR-0004) (ADR-0005) ←── esto es lo nuevo ──→
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`dai release plan` produce el **manifiesto**: qué User Stories entran entre el último tag y
|
|
40
|
+
la rama de integración, en qué estado está cada una, y qué entró **sin** declarar US. Los
|
|
41
|
+
otros comandos son formas distintas de publicar ese mismo dato — el CHANGELOG, el tag, el
|
|
42
|
+
comentario en cada ticket, el aviso al canal.
|
|
43
|
+
|
|
44
|
+
**Cómo se resuelve qué US entró:** leyendo los `implements.yaml` tal como estaban en cada
|
|
45
|
+
commit del rango, no por el nombre de la rama. El link viaja con el código, así que la
|
|
46
|
+
respuesta sobrevive a que la rama se borre y a que el change se archive — que es
|
|
47
|
+
exactamente el estado del repo cuando se llega a cortar la versión, días después del merge.
|
|
48
|
+
|
|
49
|
+
**Alternativa descartada:** deducirlo del nombre de la rama en el commit de merge. Es más
|
|
50
|
+
simple y es frágil: desaparece con squash, con rebase, y con cualquier equipo que renombre.
|
|
51
|
+
Se conserva solo como señal secundaria, para detectar lo que entró **sin** link.
|
|
52
|
+
|
|
53
|
+
### 2. El bump se propone; lo firma una persona
|
|
54
|
+
|
|
55
|
+
dai deriva un piso del tipo de los commits (`feat:` → minor, `!` → major) y **lo dice**: es
|
|
56
|
+
una propuesta, no un veredicto. La regla que manda mira el **comportamiento observable**.
|
|
57
|
+
|
|
58
|
+
La evidencia es de este mismo repo: la versión 0.14.0 salió con cuatro commits `fix:` y era
|
|
59
|
+
minor, porque cambió un default que ve quien no configura nada. Cualquier herramienta que
|
|
60
|
+
derive la versión de los tipos de commit —semantic-release, standard-version, Conventional
|
|
61
|
+
Commits puro— habría cortado un patch equivocado.
|
|
62
|
+
|
|
63
|
+
**Alternativa descartada:** versionado automático desde los commits. Es precisamente el
|
|
64
|
+
pedazo que no hay que automatizar: convierte una decisión de comunicación en un efecto
|
|
65
|
+
secundario de cómo alguien tituló un commit.
|
|
66
|
+
|
|
67
|
+
### 3. El corte son dos comandos, porque hay una firma humana en el medio
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
plan ──▶ [FIRMA: la versión] ──▶ cut ──▶ dai pr ──▶ [FIRMA: merge + publicar]
|
|
71
|
+
│
|
|
72
|
+
done ◀────────┘
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`cut` **prepara y no habla hacia afuera**: rama de release, número, entrada de CHANGELOG,
|
|
76
|
+
commit. Ni push, ni tag, ni PR.
|
|
77
|
+
|
|
78
|
+
`done` **cierra después del merge**: tag anotado, release note, back-merge y aviso. Existe
|
|
79
|
+
como comando separado porque los dos pasos que más se olvidan cuando la ceremonia se hace a
|
|
80
|
+
mano —el release note y el back-merge— viven en esta mitad, la que queda después de la firma.
|
|
81
|
+
|
|
82
|
+
**Una vez creado el tag, ningún paso posterior aborta.** El tag es la versión: si existe, la
|
|
83
|
+
versión existe. Fallar y salir dejaría el corte a medio camino sin decir en qué mitad quedó,
|
|
84
|
+
así que cada paso reporta y sigue.
|
|
85
|
+
|
|
86
|
+
### 4. El tag es la versión; los archivos son espejos
|
|
87
|
+
|
|
88
|
+
`dai release cut` sube el número en `VERSION` y `package.json` **si existen**, informa cuáles
|
|
89
|
+
tocó, y no se planta si no hay ninguno. Un repo .NET o un frontend corporativo versionan
|
|
90
|
+
igual de bien sin ninguno de los dos.
|
|
91
|
+
|
|
92
|
+
El bump de `package.json` es **quirúrgico** —solo la línea de la versión—: reserializar el
|
|
93
|
+
JSON reformatea los objetos compactos y llena el diff de la release de ruido que nadie pidió.
|
|
94
|
+
|
|
95
|
+
### 5. El CHANGELOG lo escribe una persona; dai deja el material
|
|
96
|
+
|
|
97
|
+
`cut` inserta la entrada con las US del manifiesto **en un comentario HTML** (se ve al
|
|
98
|
+
editar, desaparece al renderizar) y las secciones vacías. La prosa no la escribe dai: sabe
|
|
99
|
+
*qué* entró, no *por qué importa*.
|
|
100
|
+
|
|
101
|
+
**Alternativa descartada:** generar el changelog desde los subjects de los commits. Produce
|
|
102
|
+
una lista que nadie lee, y encima da la sensación de que el trabajo está hecho.
|
|
103
|
+
|
|
104
|
+
### 6. Estampar la versión en cada US es opcional, y avisa su alcance
|
|
105
|
+
|
|
106
|
+
`dai release stamp <v> --env <ambiente>` deja en **cada US del release** un comentario con
|
|
107
|
+
versión, app, ambiente y fecha. Con eso el funcional lee el ticket en vez de preguntar, y
|
|
108
|
+
una US federada en varios repos acumula sola su matriz (backend en producción, frontend en
|
|
109
|
+
pre).
|
|
110
|
+
|
|
111
|
+
Tres cuidados, porque escribe N veces hacia afuera en tickets de gente distinta:
|
|
112
|
+
|
|
113
|
+
- **Muestra el alcance real antes de escribir** — cuántos comentarios y en qué tickets,
|
|
114
|
+
descontando los que ya están. Un número inflado enseña a ignorar el aviso.
|
|
115
|
+
- **Es idempotente por `(app, versión, ambiente)`**, con una marca en el propio comentario.
|
|
116
|
+
Redesplegar no llena el ticket de repetidos; la misma versión en otro ambiente sí es un
|
|
117
|
+
evento nuevo.
|
|
118
|
+
- **Si no puede leer los comentarios, lo dice** en vez de suponer que no estampó. Es la
|
|
119
|
+
misma distinción que ADR-0003 hace entre "no hay US" y "no hubo respuesta"; acá afirmar de
|
|
120
|
+
más se paga en duplicados que nadie puede borrar.
|
|
121
|
+
|
|
122
|
+
**Decir que no sale con 0.** Para cuando el comando corre, el tag y el release note ya
|
|
123
|
+
existen: la versión está hecha. Que un equipo elija no hacer ruido en veinte tickets es una
|
|
124
|
+
decisión legítima, no un error a corregir.
|
|
125
|
+
|
|
126
|
+
### 7. El aviso al canal es un tercer adaptador, y su mensaje tiene estructura sin formato
|
|
127
|
+
|
|
128
|
+
`DAI_NOTIFY` elige el backend igual que `DAI_PM` elige el tracker: `discord`, `slack`,
|
|
129
|
+
`webex`, `telegram`, un `webhook` genérico, o `none` (el default: dai no habla hacia afuera
|
|
130
|
+
sin que se lo pidan).
|
|
131
|
+
|
|
132
|
+
El mensaje es **uno solo para todos los canales**, con estructura —qué salió, quién, cuándo,
|
|
133
|
+
qué trae, dónde mirar— y **sin formato**. La distinción es de costo, no estética: la
|
|
134
|
+
estructura son saltos de línea y viñetas, y se ve igual en los cinco; el formato son cuatro
|
|
135
|
+
dialectos incompatibles (Discord con `**negrita**` y sin links con nombre, Slack con
|
|
136
|
+
`*negrita*` y `<url|texto>`, Webex con markdown completo, Telegram con `parse_mode` y el
|
|
137
|
+
escapeo de MarkdownV2 que devuelve 400 por un punto suelto). Con esa decisión, el adaptador
|
|
138
|
+
es una tabla de cinco líneas en lugar de un módulo de render.
|
|
139
|
+
|
|
140
|
+
**Las viñetas son las User Stories, no los subjects de los commits.** Un aviso que dice *"se
|
|
141
|
+
implementa el conversor de propiedades no serializables"* lo entiende quien escribió el
|
|
142
|
+
código; uno que dice *"Checkout sin duplicado"* lo entiende el negocio. Es la misma
|
|
143
|
+
distinción entre el QUÉ y el CÓMO que sostiene el método, aplicada al canal.
|
|
144
|
+
|
|
145
|
+
**El endpoint es la credencial:** quien lo tiene, postea. Vive en `.env.dai` (ADR-0017) y
|
|
146
|
+
dai muestra el host, nunca la URL — tampoco en los mensajes de error.
|
|
147
|
+
|
|
148
|
+
**Qué versión hay en cada ambiente NO vive en el repo.** Un despliegue es un evento, no un
|
|
149
|
+
archivo: cambia sin que cambie el código. Guardarlo en un archivo obligaría al CI a
|
|
150
|
+
commitear en cada deploy. Su registro es el stamp en el tracker y el release del forge.
|
|
151
|
+
|
|
152
|
+
### 8. `done` borra la rama de release que acaba de cerrar
|
|
153
|
+
|
|
154
|
+
Es el único punto del ciclo donde dai puede **afirmar** que borrarla es seguro: ya está
|
|
155
|
+
mergeada en producción, etiquetada y con el back-merge hecho. Si no se hace ahí, se
|
|
156
|
+
acumulan — este mismo repo tenía cinco cuando se implementó el comando.
|
|
157
|
+
|
|
158
|
+
La red de seguridad la pone git, no una suposición: `git branch -d` (minúscula) se niega a
|
|
159
|
+
borrar una rama sin mergear. Hay precedente en el CLI: `dai done` ya hace exactamente esto
|
|
160
|
+
con la rama de una US.
|
|
161
|
+
|
|
162
|
+
**Alternativa descartada:** un `dai release cleanup` que borre ramas de release viejas en
|
|
163
|
+
masa. dai no las creó, no puede saber si alguien conserva una a propósito, y limpiar ramas
|
|
164
|
+
en general no es su dominio. `dai release status` las nombra y deja el comando escrito;
|
|
165
|
+
borrarlas es del equipo.
|
|
166
|
+
|
|
167
|
+
### 9. Lo que dai NO hace
|
|
168
|
+
|
|
169
|
+
- **No despliega.** Llega hasta el tag y vuelve a aparecer después, estampando. Quién
|
|
170
|
+
despliega es el pipeline.
|
|
171
|
+
- **No mergea ni publica.** Son firmas humanas ([Art. 5](../MANIFIESTO.md#art-5)).
|
|
172
|
+
- **No impone un modelo de branching.** La rama de release es opcional (`--no-branch`), y
|
|
173
|
+
las dos ramas de vida larga las declara el repo (`DAI_BRANCH_DEV` / `DAI_BRANCH_PROD`).
|
|
174
|
+
- **No es un framework de notificaciones.** Avisa eventos de release con su manifiesto.
|
|
175
|
+
|
|
176
|
+
## Consecuencias
|
|
177
|
+
|
|
178
|
+
- Un equipo puede contestar, sin reunirse: **qué versión hay en cada ambiente y qué US
|
|
179
|
+
comprende**. Desde el ticket, no desde un Excel.
|
|
180
|
+
- El manifiesto expone, antes de cortar, **las US atrasadas** y **lo que entró sin link** —
|
|
181
|
+
la última pantalla donde eso se puede ver.
|
|
182
|
+
- El ciclo funciona igual con rama de release (ventana de estabilización) y sin ella (tag
|
|
183
|
+
directo desde integración), así que dai no fuerza a nadie a cambiar de estrategia para
|
|
184
|
+
ganar trazabilidad.
|
|
185
|
+
- Aparecen dos variables nuevas de config (`DAI_NOTIFY`, `DAI_NOTIFY_WEBHOOK`, más
|
|
186
|
+
`DAI_NOTIFY_CHAT_ID` solo para Telegram) y dos métodos nuevos en el adaptador de PM
|
|
187
|
+
(`comment`, `listComments`), que cualquier backend nuevo tiene que implementar para
|
|
188
|
+
soportar el estampado de versión.
|
|
189
|
+
- La skill `/dai-release` conduce el ciclo pero **no recalcula nada**: narra lo que dicen
|
|
190
|
+
los comandos. Si la skill y el CLI se contradicen, gana el CLI.
|
package/docs/adr/README.md
CHANGED
|
@@ -24,6 +24,7 @@ decisión cambia, se escribe un ADR nuevo que supersede al viejo. Molde en
|
|
|
24
24
|
| [0016](0016-review-inline.md) | Review inline: `review.json` como contrato y puerta humana, el CLI valida las posiciones contra el diff, `--yes` explícito, nunca `APPROVE` | aceptado |
|
|
25
25
|
| [0017](0017-env-dai.md) | La config de dai vive en `.env.dai` (no versionado), no en el `.env` del equipo; el loader lee ambos con precedencia shell > `.env.dai` > `.env` | aceptado |
|
|
26
26
|
| [0018](0018-alcance-de-stamp-y-gate-de-ci.md) | El alcance de `dai stamp` lo decide la rama (ante la duda pregunta, no estampa de más), `dai check --ci` ejecuta el gate de governance con ramas exentas, y `dai edit-us`/`update-us` editan el QUÉ validando el formato y proponiendo el `spec_version` | aceptado |
|
|
27
|
+
| [0019](0019-ciclo-de-version-y-aviso-de-release.md) | El ciclo de versión: el manifiesto de US como dato central, el bump se propone y lo firma una persona, el corte son dos comandos con una firma en el medio, el tag es la versión y los archivos son espejos, estampar es opcional y avisa su alcance, y el canal es un tercer adaptador con estructura sin formato | aceptado |
|
|
27
28
|
|
|
28
29
|
> Estas son las decisiones que cierran las "Decisiones abiertas" de
|
|
29
30
|
> [`METODOLOGIA.md §7`](../METODOLOGIA.md) y las enmiendas al
|
package/docs/guias/index.md
CHANGED
|
@@ -7,6 +7,9 @@ y tu **día a día**.
|
|
|
7
7
|
porqué, nunca el CÓMO. Entrás por `grill-user-story`, `grill-epic` o `doc-to-backlog`.
|
|
8
8
|
- [**Guía del dev / ingeniero**](./dev) — dueño del **CÓMO** y del **link**: el agente
|
|
9
9
|
implementa con `/opsx:apply`, tú revisas y autoras el `implements.yaml`.
|
|
10
|
+
- [**Guía de releases**](./releases) — el *porqué* del versionado: qué problema resuelve un
|
|
11
|
+
tag, por qué "una rama por ambiente" falla, los dos modelos y la pregunta que elige entre
|
|
12
|
+
ellos. Transversal: la lee el lead para decidir y el dev para entender qué firma.
|
|
10
13
|
- [**Guía del lead / SM / arquitecto**](./lead) — custodio de las **invariantes** y del
|
|
11
14
|
**nivel de ceremonia** (N1 / N2 / N3): haces que el método se cumpla y se aligere donde
|
|
12
15
|
corresponde.
|