Evitar que vuelva el vacío de posts sin imagen hero (una protección, no un recordatorio)
Hace un tiempo hice una tarea aburrida y satisfactoria: encontré 80 posts de blog en mi sitio que no tenían imagen hero y le di una a cada uno. Se sintió como progreso. No lo fue, en realidad. Una limpieza de una sola vez arregla el día de hoy y no promete nada sobre el mañana, y un par de meses después fui a revisar y encontré que el vacío había vuelto a abrirse en silencio. Cuatro posts publicados, sin imagen hero.
Esta es la historia del segundo arreglo, el que sí se sostiene.
Por qué el vacío seguía volviendo
Mi blog es un sitio de Astro. Los metadatos de los posts viven en el frontmatter, validados por un esquema de Zod. Esta es la línea relevante:
heroImage: z.string().optional(),
Optional. Esa palabra es todo el problema. Mi flujo de publicación verifica que los campos que un post tiene sean válidos, pero un campo opcional ausente es válido por definición. Así que un post sin imagen hero pasa todas las verificaciones sin problema, se confirma, se despliega, y sale en vivo viéndose un poco desnudo. Nada se queja nunca. La única forma en que me enteré fue yendo a contar.
El arreglo obvio es hacer el campo obligatorio:
heroImage: z.string(), // tempting, wrong
No hice eso, y quiero explicar por qué, porque es la decisión de la que depende el resto del trabajo. Un campo obligatorio hace fallar el build para todo post sin imagen hero, incluyendo borradores que todavía estoy escribiendo, fechados para la semana próxima y lejos de estar listos. Quedaría bloqueado por una imagen antes de haber terminado la prosa. El esquema no puede distinguir “publicado y roto” de “todavía no terminado”. Necesitaba una verificación que sí pudiera.
La protección
La distinción que realmente me importa es: ¿este post está en vivo? Un post está en vivo cuando su pubDate es hoy o antes. Los posts con fecha futura son borradores y no son asunto de la protección. Ese único filtro es toda la idea.
const today = new Date().toISOString().slice(0, 10);
for (const file of files) {
const { data } = matter(await readFile(join(BLOG_DIR, file), 'utf8'));
const pubDate = String(data.pubDate ?? '').slice(0, 10);
// Only enforce on published posts; drafts are exempt.
if (!pubDate || pubDate > today) continue;
const hero = typeof data.heroImage === 'string' ? data.heroImage.trim() : '';
if (!hero) violations.push(file.replace(/\.md$/, ''));
}
Notar el .trim(). El esquema acepta un string vacío, así que heroImage: "" pasaría una verificación de presencia ingenua mientras está exactamente tan roto como no tener imagen hero en absoluto. Tratar lo vacío como ausente.
Corre como un hook de pre-commit y de nuevo en CI. Localmente detiene un post sin imagen hero antes de que se confirme. En CI es el respaldo para todo lo que se salte el hook. El mismo script en ambos lugares.
La parte que casi hice mal
Mi CI corre los hooks contra todo el repositorio, cada archivo, cada vez (pre-commit run --all-files). Es un buen valor por defecto y tiene un filo peligroso: una protección recién creada, apuntada a un corpus con violaciones preexistentes, falla el primer día. Ya tenía un hook esquivando esto. Mi verificación de palabras prohibidas se salta explícitamente en CI por esta misma razón, porque escanear todo el historial saca a la luz frases viejas con las que ya hice las paces.
Podría haber copiado eso y ponerle un SKIP a la protección nueva. No lo hice, y esto es lo que subrayaría para cualquiera que construya algo parecido. Saltarla en CI habría significado publicar una protección que no protege. Los cuatro posts rotos quedarían ahí, exentos para siempre, y la verificación solo atraparía errores nuevos mientras mentiría sobre el estado actual. Así que hice lo menos ingenioso: arreglé los cuatro posts primero, para que la protección pudiera correr contra todo sin excepciones y pasar honestamente. Una protección con un asterisco no es una protección.
El ayudante, y una sorpresa genuina
Para arreglar los cuatro (y para que fijar una imagen hero sea de una sola línea de acá en adelante), escribí un pequeño ayudante. Se le da un slug y una imagen, desde una URL o un archivo local, y descarga la foto, escribe el archivo webp hermano con la misma configuración de sharp que ya usa el build, e inserta la línea de frontmatter:
node scripts/set-hero-image.mjs <slug> <image-url-or-path>
A propósito lo mantuve como una edición de texto, no un parseo y reescritura. Hacer el viaje de ida y vuelta del frontmatter por una librería de YAML habría reacomodado el estilo de comillas y reordenado las claves en los cuatro archivos, enterrando un cambio de una línea en un diff ruidoso. Una inserción dirigida mantiene el diff a la única línea que cambió.
La sorpresa fue el conteo. Esperaba dos posts rotos y encontré cuatro. Uno de los dos extra ni siquiera estaba confirmado. Era un borrador sentado en mi árbol de trabajo, nunca subido, con un pubDate retroactivo que hacía que la protección lo tratara como en vivo. Mi primera reacción fue que la protección tenía un falso positivo. Mi segunda reacción, mejor, fue que la protección tenía razón y yo estaba equivocado: un post fechado en el pasado le está diciendo al mundo que está publicado, y si alguna vez lo confirmo, se publica sin imagen hero. La herramienta atrapó un error que todavía no había cometido. Ese es el buen tipo de molestia.
Lo que me llevo de esto
El patrón que sigo reaprendiendo: una checklist dice qué hay que hacer, una protección hace imposible confirmar lo incorrecto. También actualicé mi flujo de publicación para fijar una imagen hero como un paso, y eso es genuinamente útil, pero es una nota para mi yo futuro, y mi yo futuro es olvidadizo. La pieza que sostiene todo son las doce líneas que hacen fallar el build. Todo lo demás es conveniencia.
Y cuando se agrega una protección a una base de código que ya tiene violaciones, hay que resistir la tentación de saltarla. Limpiar las violaciones primero para que la protección pueda ser absoluta. El momento en que tiene una excepción es el momento en que deja de significar algo.
Lecturas relacionadas
El formateador que nunca revisó su propia carpeta de scripts
Un gate de despliegue que corrió, pasó, y nunca miró el directorio que contiene el script que se ejecuta antes de que empiece el build.
Retirando el entorno de staging
Un segundo contenedor, un monitor aparte, una tasa de fallos del 11% en el workflow, y cero evidencia de que alguna vez detectara algo que los deploys de producción no detectaran. La auditoría que terminó en un borrado.
Dos triggers, un id de KV: por qué los builds de preview estaban a oscuras en imperfectsystems.com
Investigando por qué los builds de ramas que no son de producción nunca corrían en Cloudflare Workers Builds, un namespace de KV que nadie pidió, y un modelo de triggers que había malinterpretado.