Auto-publicar posts del blog en LinkedIn con GitHub Actions + n8n
Introduccion
Esta es la segunda parte de la serie:
En esta guia vamos a construir un flujo real de autopublicacion para evitar trabajo manual cada vez que publicas un post tecnico.
Que cubre exactamente:
- Como detectar en GitHub Actions que hay un post nuevo o actualizado bajo
src/content/blog/. - Como enviar un payload estandar a
n8ncon datos utiles del post. - Como proteger el webhook con un secreto compartido para que no publique cualquiera.
- Como transformar los datos en un texto listo para LinkedIn.
- Como conectar LinkedIn OAuth en n8n y publicar en tu perfil personal.
- Como validar todo el flujo de extremo a extremo usando un tunel publico (ngrok o cloudflared).
Objetivo final: que al hacer push a main, primero se complete el deploy y solo despues se dispare la publicacion en LinkedIn, con trazabilidad y control de errores.
Nota de validacion (2026-03-03): este post se actualiza para verificar el disparo real de Blog -> n8n webhook tras un deploy exitoso.
Arquitectura del flujo
- Cambia un post en
src/content/blog/. - Haces push o merge a
main. - Se ejecuta
Deploy Astro to Hostinger. - Si el deploy termina en
success, se ejecutaBlog -> n8n webhookviaworkflow_run. - GitHub Actions envia
POSTal webhook den8n. n8nvalida token, transforma el payload y publica en LinkedIn.n8nresponde con estado HTTP y deja traza enExecutions.
Prerrequisitos
n8nlocal operativo.- Repositorio con acceso a
Settings -> Secrets and variables -> Actions. - URL publica del portfolio:
https://gontzalbilbao.com. - LinkedIn personal listo para publicar.
- App en LinkedIn Developer para OAuth2.
Importante:
- GitHub Actions no puede llegar a
localhost. N8N_WEBHOOK_URLdebe ser publico (ngrok, Cloudflare Tunnel, n8n cloud o VPS).
Fase 1: GitHub Actions (detectar y notificar)
1.1 Secretos del repositorio
En GitHub crea:
N8N_WEBHOOK_URL
- Ejemplo:
https://tu-tunel.ngrok-free.dev/webhook/blog-linkedin-autopost
N8N_WEBHOOK_TOKEN
- Secreto compartido que validara el nodo
IFen n8n.
1.2 Trigger real usado en esta guia
Archivo: .github/workflows/blog-to-n8n.yml
Trigger configurado:
workflow_rundel workflowDeploy Astro to Hostingercon estadocompleted- se ejecuta solo cuando ese deploy acaba en
successy la rama esmain workflow_dispatchmanual disponible
Orden de ejecucion en produccion:
pushamain- corre
Deploy Astro to Hostinger - si deploy =
success, correBlog -> n8n webhook - n8n publica en LinkedIn
1.3 Criterio exacto de “post publicado”
El workflow envia evento por archivo blog cambiado (.md o .mdx) cuando:
published: true, o- no existe
published(fallback del script:true)
No envia evento cuando:
published: false- el cambio no esta en
src/content/blog/**
Nota tecnica de deteccion:
- en commits normales compara
HEAD~1..HEAD - en commits de merge compara
HEAD^1..HEADpara incluir todos los cambios del PR - esto evita falsos
No changed blog markdown files. Nothing to notify.cuando el merge si incluyo posts
1.4 Payload real enviado a n8n
{
"event": "blog_published",
"source": "github-actions",
"repo": "owner/repo",
"branch": "main",
"commit_sha": "abc123",
"idempotency_key": "slug:abc123",
"post": {
"slug": "mi-post",
"title": "Titulo del post",
"description": "Descripcion",
"category": "Automatizaciones",
"tags": ["n8n", "GitHub Actions"],
"highlights": [
"Fase 1: GitHub Actions",
"Fase 2: Workflow n8n",
"Fase 3: Test real de extremo a extremo"
],
"url": "https://gontzalbilbao.com/blog/mi-post/",
"published": true
}
}
Por que este payload:
eventysourcepermiten filtrar en n8n.idempotency_keyayuda a controlar duplicados.commit_shayrepofacilitan trazabilidad.post.highlightssale del propio markdown (primeros##/###) para enriquecer el copy.post.urlllega lista para publicar.
1.5 Workflow real guardado en el repo
El workflow usado en esta guia esta en:
.github/workflows/blog-to-n8n.yml
Puntos clave del fichero:
- trigger por
workflow_rundel deploy (Deploy Astro to Hostinger) - se ejecuta solo con deploy
successen ramamain workflow_dispatchpara ejecucion manual- validacion de secreto
N8N_WEBHOOK_URL - bloqueo de
localhosten runners de GitHub - deteccion de archivos blog cambiados
- parseo de frontmatter (
title,description,category,tags,published) - envio de webhook por cada post publicado
Fragmento relevante:
on:
workflow_run:
workflows:
- "Deploy Astro to Hostinger"
types:
- completed
workflow_dispatch:
1.6 Workflow completo (.github/workflows/blog-to-n8n.yml)
A continuacion se incluye el workflow completo usado en esta implementacion.
Si haces cambios, edita primero .github/workflows/blog-to-n8n.yml y despues actualiza este bloque para mantener sincronizada la guia.
name: Blog -> n8n webhook
on:
workflow_run:
workflows:
- "Deploy Astro to Hostinger"
types:
- completed
workflow_dispatch:
jobs:
notify-n8n:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
env:
N8N_WEBHOOK_URL: ${{ secrets.N8N_WEBHOOK_URL }}
N8N_WEBHOOK_TOKEN: ${{ secrets.N8N_WEBHOOK_TOKEN }}
SITE_URL: "https://gontzalbilbao.com"
AFTER_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
REPO: ${{ github.repository }}
REF_NAME: ${{ github.event.workflow_run.head_branch || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Validate required secret
run: |
if [ -z "${N8N_WEBHOOK_URL}" ]; then
echo "N8N_WEBHOOK_URL is not set. Configure it in GitHub repository secrets."
exit 1
fi
- name: Detect published blog posts and notify n8n
shell: bash
run: |
node <<'EOF'
const fs = require('fs');
const cp = require('child_process');
const path = require('path');
const webhookUrl = process.env.N8N_WEBHOOK_URL;
const webhookToken = process.env.N8N_WEBHOOK_TOKEN || '';
const siteUrl = process.env.SITE_URL || 'https://gontzalbilbao.com';
const after = process.env.AFTER_SHA;
const repo = process.env.REPO;
const branch = process.env.REF_NAME;
if (!webhookUrl) {
console.error('Missing N8N_WEBHOOK_URL secret.');
process.exit(1);
}
if (/localhost|127\.0\.0\.1/i.test(webhookUrl)) {
console.error('N8N_WEBHOOK_URL points to localhost. GitHub runners cannot reach local endpoints.');
process.exit(1);
}
let changed = [];
try {
// For merge commits, diff against first parent to include all PR file changes.
const parentsRaw = cp.execSync(`git rev-list --parents -n 1 ${after}`, { encoding: 'utf8' }).trim();
const parents = parentsRaw ? parentsRaw.split(' ') : [];
const isMergeCommit = parents.length > 2;
const range = isMergeCommit ? `${after}^1 ${after}` : `${after}~1 ${after}`;
const out = cp
.execSync(`git diff --name-only ${range} -- src/content/blog`, { encoding: 'utf8' })
.trim();
changed = out ? out.split('\n') : [];
} catch (error) {
console.error('Unable to diff changed files:', error.message);
process.exit(1);
}
const postFiles = changed.filter((file) => /\.(md|mdx)$/i.test(file));
if (!postFiles.length) {
console.log('No changed blog markdown files. Nothing to notify.');
process.exit(0);
}
const parseFrontmatter = (content) => {
const normalized = content.replace(/^\uFEFF/, '');
const match = normalized.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return {};
const block = match[1];
const get = (key) => {
const rx = new RegExp(`^${key}:\\s*(.+)$`, 'm');
const m = block.match(rx);
return m ? m[1].trim() : '';
};
const cleanup = (value) => value.replace(/^["']|["']$/g, '').trim();
const tagsRaw = get('tags');
let tags = [];
if (tagsRaw.startsWith('[') && tagsRaw.endsWith(']')) {
tags = tagsRaw
.slice(1, -1)
.split(',')
.map((v) => cleanup(v))
.filter(Boolean);
}
const publishedRaw = cleanup(get('published'));
// Only an explicit "false" should skip publication.
const published = publishedRaw === '' ? true : !/^false(\s|$)/i.test(publishedRaw);
return {
title: cleanup(get('title')),
description: cleanup(get('description')),
category: cleanup(get('category')),
tags,
published,
publishedRaw,
};
};
const extractHighlights = (content) => {
// Remove frontmatter and read markdown headings as dynamic highlights.
const withoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/m, '');
const lines = withoutFrontmatter.split('\n');
const highlights = [];
for (const line of lines) {
const h2 = line.match(/^##\s+(.+?)\s*$/);
const h3 = line.match(/^###\s+(.+?)\s*$/);
const heading = h2?.[1] || h3?.[1];
if (!heading) continue;
const cleaned = heading
.replace(/[`*_>#]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) continue;
if (/^introducci[oó]n$/i.test(cleaned)) continue;
if (/^conclusi[oó]n$/i.test(cleaned)) continue;
highlights.push(cleaned);
if (highlights.length === 3) break;
}
return highlights;
};
const toSlug = (file) => path.basename(file).replace(/\.(md|mdx)$/i, '');
const normalizeSite = (url) => url.replace(/\/+$/, '');
const notify = async (payload) => {
const headers = { 'Content-Type': 'application/json' };
if (webhookToken) headers['x-webhook-token'] = webhookToken;
const res = await fetch(webhookUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`Webhook failed (${res.status}): ${text}`);
}
console.log(`Webhook accepted for ${payload.post.slug}: ${res.status}`);
};
(async () => {
let sent = 0;
for (const file of postFiles) {
if (!fs.existsSync(file)) continue;
const raw = fs.readFileSync(file, 'utf8');
const fm = parseFrontmatter(raw);
if (fm.published === false) {
console.log(`Skipping ${file}: published is false (raw: "${fm.publishedRaw || ''}").`);
continue;
}
const slug = toSlug(file);
const payload = {
event: 'blog_published',
source: 'github-actions',
repo,
branch,
commit_sha: after,
idempotency_key: `${slug}:${after}`,
post: {
slug,
title: fm.title || slug,
description: fm.description || '',
category: fm.category || '',
tags: fm.tags || [],
highlights: extractHighlights(raw),
url: `${normalizeSite(siteUrl)}/blog/${slug}/`,
published: true,
},
};
await notify(payload);
sent += 1;
}
console.log(`Done. Webhook notifications sent: ${sent}`);
})().catch((error) => {
console.error(error.message);
process.exit(1);
});
EOF
Fase 2: Workflow n8n (webhook + seguridad + transformacion)
Nombre recomendado del workflow:
blog-linkedin-autopost
Nombre usado en esta implementacion:
blog-linkedin-mvp-56
2.1 Diagrama objetivo
Rama true:
Webhook -> IF -> Edit Fields -> Create a post (LinkedIn) -> Respond to Webhook
Rama false:
IF -> Respond to Webhook - False
Por que esta estructura:
Webhookdesacopla GitHub Actions de LinkedIn.IFevita publicaciones no autorizadas.Edit Fieldsnormaliza entradas y evita depender del formato bruto.Create a postconcentra la integracion con LinkedIn.Respond to Webhookdevuelve estado claro para depurar desde Actions.
2.2 Nodo Webhook
- Method:
POST - Path:
blog-linkedin-mvp-56(oblog-linkedin-autopostsi partes desde cero) - Authentication:
None - Respond:
Using Respond to Webhook Node
URLs:
- Test:
http://localhost:5678/webhook-test/blog-linkedin-mvp-56 - Production:
http://localhost:5678/webhook/blog-linkedin-mvp-56
2.3 Nodo IF (token)
Condicion:
- Izquierda:
{{$json.headers["x-webhook-token"] || ""}} - Operador:
is equal to - Derecha:
TU_SECRETO_WEBHOOK_PERSONALIZADO
Resultado esperado:
- secreto correcto -> rama
true - sin secreto o secreto incorrecto -> rama
false
Por que se usa header en vez de query string:
- evita exponer secretos en URLs
- facilita rotacion del secreto en GitHub y n8n
- reduce riesgo de logs con secreto visible
2.4 Nodo Edit Fields (rama true)
Campos:
title:{{$json.body.post.title || ""}}description:{{$json.body.post.description || ""}}url:{{$json.body.post.url || ""}}category:{{$json.body.post.category || ""}}slug:{{$json.body.post.slug || ""}}idempotency_key:{{$json.body.idempotency_key || ""}}tags:{{($json.body.post.tags || []).join(", ")}}hook:{{ (() => { const c = ($json.body.post.category || "").toLowerCase().trim(); if (c === "automatizaciones") return "Nuevo articulo en mi blog sobre automatizacion."; if (c === "certificaciones") return "Nuevo articulo en mi blog sobre certificaciones cloud."; if (c === "aws") return "Nuevo articulo en mi blog sobre AWS."; if (c === "azure") return "Nuevo articulo en mi blog sobre Azure."; if (c === "iac") return "Nuevo articulo en mi blog sobre IaC e infraestructura."; if (c === "proyectos") return "Nuevo articulo en mi blog sobre proyectos reales."; return "Nuevo articulo tecnico publicado en mi blog."; })() }}point1:{{ (($json.body.post.highlights || [])[0]) || ($json.body.post.title || "problema y objetivo del caso") }}point2:{{ (($json.body.post.highlights || [])[1]) || ($json.body.post.description || "arquitectura y decisiones tecnicas") }}point3:{{ (($json.body.post.highlights || [])[2]) || (($json.body.post.tags || []).length ? "Stack aplicado: " + ($json.body.post.tags || []).slice(0, 3).join(", ") : "resultado y aprendizajes aplicables") }}
Campo linkedin_text:
{{
(() => {
const hook = $json.hook || "Nuevo articulo tecnico publicado en mi blog.";
const p1 = $json.point1 || "el problema tecnico real";
const p2 = $json.point2 || "la arquitectura aplicada";
const p3 = $json.point3 || "el resultado y aprendizajes";
const articleUrl =
$json.url ||
$json.post?.url ||
$json.body?.post?.url ||
"https://gontzalbilbao.com/blog/mvp-auto-publicar-blog-linkedin-github-actions-n8n/";
return (
hook +
"\n\n" +
"Nuevo articulo en mi blog." +
"\n\n" +
"En este post explico:" +
"\n- " + p1 +
"\n- " + p2 +
"\n- " + p3 +
"\n\n" +
"Enlace al articulo 👇" +
"\n" + articleUrl +
"\n\n" +
"Como lo habrias implementado tu?"
);
})()
}}
2.5 linkedin_text: dinamico por cada post
linkedin_text no es fijo. Se construye con hook, point1, point2, point3 y url del post recibido.
El hook es dinamico por categoria para mantener un tono cercano y coherente con el tipo de contenido.
Los puntos salen del propio contenido del post mediante post.highlights (primeros H2/H3 del markdown). Si faltan highlights, entran fallbacks de title, description y tags.
Ejemplo 1 (instalacion n8n):
[Automatizaciones] Como instalar n8n en local con Docker (Windows): guia completa paso a paso
Nuevo articulo en mi blog.
En este post explico:
- Prerrequisitos
- Paso 1: Crear estructura base del proyecto
- Paso 2: Crear docker-compose.yml para n8n
Enlace al articulo 👇
https://gontzalbilbao.com/blog/instalar-n8n-local-primeros-pasos/
Como lo habrias implementado tu?
Ejemplo 2 (autopublicacion LinkedIn):
[Automatizaciones] Auto-publicar posts del blog en LinkedIn con GitHub Actions + n8n
Nuevo articulo en mi blog.
En este post explico:
- Fase 1: GitHub Actions (detectar y notificar)
- Fase 2: Workflow n8n (webhook + seguridad + transformacion)
- Fase 3: Publicar workflow y configurar LinkedIn OAuth
Enlace al articulo 👇
https://gontzalbilbao.com/blog/mvp-auto-publicar-blog-linkedin-github-actions-n8n/
Como lo habrias implementado tu?
Ejemplo 3 (Azure Fundamentals):
[Certificaciones] Guia AZ-900: preparacion para Azure Fundamentals
Nuevo articulo en mi blog.
En este post explico:
- plan de estudio y recursos clave para AZ-900
- enfoque practico para consolidar fundamentos cloud
- lecciones aplicables al dia a dia tecnico
Enlace al articulo 👇
https://gontzalbilbao.com/blog/guia-az-900-preparacion/
Como lo habrias implementado tu?
2.6 Nodo Respond to Webhook - False
- Response Code:
401 - Response Body:
{
"ok": false,
"branch": "false",
"message": "Unauthorized"
}
2.7 Nodo LinkedIn (Create a post)
Antes de este nodo, necesitas credenciales OAuth de LinkedIn activas.
Si todavia no las tienes, completa primero 3.4 y 3.5.
- Credential:
LinkedIn OAuth2 API - Resource:
Post - Operation:
Create - Post As:
Person - Person Name or ID: tu perfil
- Text:
{{$json.linkedin_text}}
2.8 Nodo Respond to Webhook (rama true)
- Response Code:
200 - Response Body:
{
"ok": true,
"branch": "true",
"message": "Payload procesado",
"title": "{{$json.title}}",
"url": "{{$json.url}}",
"idempotency_key": "{{$json.idempotency_key}}"
}
Por que responder al final de cada rama:
- el cliente que llama al webhook recibe feedback inmediato
- GitHub Actions puede fallar con mensaje legible si algo se rompe
- facilita soporte cuando hay errores de OAuth, scopes o formato
2.9 Imagen en la publicacion de LinkedIn
Para incluir imagen en LinkedIn con el flujo actual, usa la URL del post en el cuerpo del mensaje (como en linkedin_text).
LinkedIn leera los metadatos Open Graph del articulo y mostrara la imagen de portada del post.
En esta guia, esa portada es:
src/assets/images/blog/blog-linkedin-autopost-flow.png
Punto importante:
- si quitas la URL del cuerpo y dejas “enlace en comentario”, la previsualizacion con imagen no aparecera en la publicacion principal.
Fase 3: Publicar workflow y configurar LinkedIn OAuth
3.1 Publicar workflow n8n
- Guarda el workflow.
- Pulsa
Publish. - Verifica que quede
Active.
3.2 Prueba local (test URL)
$body = @{
event = "blog_published"
source = "manual-test"
idempotency_key = "manual-001"
post = @{
slug = "demo-post"
title = "Demo post para n8n"
description = "Prueba manual del flujo"
category = "Automatizaciones"
tags = @("n8n","github-actions")
url = "https://gontzalbilbao.com/blog/demo-post/"
published = $true
}
} | ConvertTo-Json -Depth 6
$response = Invoke-WebRequest `
-UseBasicParsing `
-Method Post `
-Uri "http://localhost:5678/webhook-test/blog-linkedin-mvp-56" `
-Headers @{ "x-webhook-token" = "TU_SECRETO_WEBHOOK_PERSONALIZADO" } `
-ContentType "application/json" `
-Body $body
$response.StatusCode
$response.Content
3.3 Prueba de codigos HTTP reales (production URL)
$uri = "http://localhost:5678/webhook/blog-linkedin-mvp-56"
Sin secreto (esperado 401):
try {
$r = Invoke-WebRequest -UseBasicParsing -Method Post -Uri $uri -ContentType "application/json" -Body $body -ErrorAction Stop
"STATUS: $($r.StatusCode)"
$r.Content
} catch {
"STATUS: $($_.Exception.Response.StatusCode.value__)"
}
Secreto incorrecto (esperado 401):
try {
$r = Invoke-WebRequest -UseBasicParsing -Method Post -Uri $uri -Headers @{ "x-webhook-token" = "SECRETO_INVALIDO" } -ContentType "application/json" -Body $body -ErrorAction Stop
"STATUS: $($r.StatusCode)"
$r.Content
} catch {
"STATUS: $($_.Exception.Response.StatusCode.value__)"
}
Secreto correcto (esperado 200):
$r = Invoke-WebRequest -UseBasicParsing -Method Post -Uri $uri -Headers @{ "x-webhook-token" = "TU_SECRETO_WEBHOOK_PERSONALIZADO" } -ContentType "application/json" -Body $body
"STATUS: $($r.StatusCode)"
$r.Content
3.4 Bloque final linkedin_text (copiar/pegar)
En Edit Fields, deja linkedin_text asi:
{{
(() => {
const hook = $json.hook || "Nuevo articulo tecnico publicado en mi blog.";
const p1 = $json.point1 || "el problema tecnico real";
const p2 = $json.point2 || "la arquitectura aplicada";
const p3 = $json.point3 || "el resultado y aprendizajes";
const articleUrl =
$json.url ||
$json.post?.url ||
$json.body?.post?.url ||
"https://gontzalbilbao.com/blog/mvp-auto-publicar-blog-linkedin-github-actions-n8n/";
return (
hook +
"\n\n" +
"Nuevo articulo en mi blog." +
"\n\n" +
"En este post explico:" +
"\n- " + p1 +
"\n- " + p2 +
"\n- " + p3 +
"\n\n" +
"Enlace al articulo 👇" +
"\n" + articleUrl +
"\n\n" +
"Como lo habrias implementado tu?"
);
})()
}}
3.5 Test rapido de 3 casos reales (antes de push a main)
Usa este comando para enviar payloads al webhook de produccion local:
$uri = "http://localhost:5678/webhook/blog-linkedin-mvp-56"
$token = "TU_SECRETO_WEBHOOK_PERSONALIZADO"
function Send-TestPayload($post) {
$body = @{
event = "blog_published"
source = "manual-test"
idempotency_key = "$($post.slug):manual-test"
post = $post
} | ConvertTo-Json -Depth 8
try {
$r = Invoke-WebRequest -UseBasicParsing -Method Post -Uri $uri -Headers @{ "x-webhook-token" = $token } -ContentType "application/json" -Body $body -ErrorAction Stop
"STATUS: $($r.StatusCode)"
$r.Content
} catch {
"STATUS: $($_.Exception.Response.StatusCode.value__)"
}
}
Caso A - instalar-n8n-local-primeros-pasos:
$postA = @{
slug = "instalar-n8n-local-primeros-pasos"
title = "Como instalar n8n en local con Docker (Windows): guia completa paso a paso"
description = "Guia detallada para instalar y validar n8n en local con Docker."
category = "Automatizaciones"
tags = @("n8n","docker","windows")
highlights = @(
"Prerrequisitos",
"Paso 1: Crear estructura base del proyecto",
"Paso 2: Crear docker-compose.yml para n8n"
)
url = "https://gontzalbilbao.com/blog/instalar-n8n-local-primeros-pasos/"
published = $true
}
Send-TestPayload $postA
Caso B - mvp-auto-publicar-blog-linkedin-github-actions-n8n:
$postB = @{
slug = "mvp-auto-publicar-blog-linkedin-github-actions-n8n"
title = "Auto-publicar posts del blog en LinkedIn con GitHub Actions + n8n"
description = "MVP completo para disparar publicaciones en LinkedIn desde cambios en el blog."
category = "Automatizaciones"
tags = @("n8n","github-actions","linkedin","automatizacion")
highlights = @(
"Fase 1: GitHub Actions (detectar y notificar)",
"Fase 2: Workflow n8n (webhook + seguridad + transformacion)",
"Fase 3: Publicar workflow y configurar LinkedIn OAuth"
)
url = "https://gontzalbilbao.com/blog/mvp-auto-publicar-blog-linkedin-github-actions-n8n/"
published = $true
}
Send-TestPayload $postB
Caso C - guia-az-900-preparacion:
$postC = @{
slug = "guia-az-900-preparacion"
title = "Guia AZ-900: preparacion para Azure Fundamentals"
description = "Ruta de estudio estructurada para preparar AZ-900."
category = "Certificaciones"
tags = @("azure","az-900","fundamentals")
highlights = @(
"plan de estudio y recursos clave para AZ-900",
"enfoque practico para consolidar fundamentos cloud",
"lecciones aplicables al dia a dia tecnico"
)
url = "https://gontzalbilbao.com/blog/guia-az-900-preparacion/"
published = $true
}
Send-TestPayload $postC
Validacion esperada:
- codigo
200en cada llamada valida - ejecucion
trueen n8n (no ramafalse) Create a postsin error- texto de LinkedIn distinto por post (hook + puntos)
3.4 Crear LinkedIn Page (requisito para LinkedIn Developer)
LinkedIn Developer pide asociar la app a una Page. Para este flujo:
- Crea una Page minima desde LinkedIn.
- Campos recomendados:
- Nombre:
Gontzal Bilbao Tech - URL publica:
gontzal-bilbao-tech - Sitio web:
https://gontzalbilbao.com - Industria: tecnologia / software
- Tamano:
1-10 - Tipo:
Self-employedoPrivately held - Logo y lema breves
3.5 Crear App en LinkedIn Developer y credencial OAuth en n8n
LinkedIn Developer -> My Apps -> Create app
- Asocia la app a la Page creada.
- Completa nombre, logo y URL de privacidad.
Auth -> OAuth 2.0 settings
- En
Authorized redirect URLsanade exactamente: http://localhost:5678/rest/oauth2-credential/callback
Products / Scopes
- Anade productos obligatorios en la app:
Share on LinkedIn(Default Tier)Sign In with LinkedIn using OpenID Connect(Standard Tier)- Despues, verifica scopes:
openidprofileemailw_member_social
- En n8n, crea credencial
LinkedIn OAuth2 API
- Pega
Client IDyClient Secret. Organization Support: desactivado (publicacion personal).- Conecta OAuth.
Fase 4: Test real con GitHub Actions
4.1 Levantar endpoint publico (ngrok o cloudflared)
GitHub Actions necesita acceder a tu n8n local desde internet. Para eso se usa un tunel.
Opcion A (ngrok, usada en esta guia):
- Instalar:
winget install ngrok.ngrok
- Configurar token de ngrok (desde tu dashboard):
ngrok config add-authtoken TU_NGROK_AUTHTOKEN_REAL
- Levantar tunel a n8n local:
ngrok http 5678
- Copiar URL publica HTTPS, por ejemplo:
https://unstammering-unhewed-beatrice.ngrok-free.dev
Opcion B (cloudflared):
cloudflared tunnel --url http://localhost:5678
4.2 Configurar secretos de GitHub para el test E2E
En el repo, define:
N8N_WEBHOOK_URL=https://<tu-tunel>/webhook/blog-linkedin-mvp-56N8N_WEBHOOK_TOKEN= el mismo secreto del nodoIF
Tambien verifica:
- Workflow n8n publicado y activo.
- Nodo Webhook con path correcto (
blog-linkedin-mvp-56).
4.3 Forzar evento de blog
- Edita un post publicado en
src/content/blog/. - Haz commit y push a
main(o merge de PR amain). - Revisa en GitHub Actions que termine primero
Deploy Astro to Hostinger. - Revisa despues la ejecucion de
Blog -> n8n webhook(jobnotify-n8n). - Revisa en n8n
Executions. - Verifica que se haya creado la publicacion en LinkedIn.
Con esto queda validado el circuito completo GitHub Actions -> n8n -> LinkedIn.
4.4 Verificar ejecucion en GitHub Actions
En la run de Blog -> n8n webhook, busca:
- linea de notificacion enviada por slug
- estado
Webhook accepted for <slug>: 200
Si falla:
401suele ser secreto desalineado (N8N_WEBHOOK_TOKENvs nodoIF)404suele ser path de webhook incorrecto o workflow n8n no activo5xxsuele ser error interno del flujo o credenciales LinkedInNo changed blog markdown files. Nothing to notify.significa que ese commit no trae cambios ensrc/content/blog/**
4.5 Verificar ejecucion en n8n
En Executions, valida:
Webhookrecibe el payload esperado.IFpasa por ramatruecon secreto correcto.Edit Fieldsgeneralinkedin_text.Create a postdevuelvesuccess.Respond to Webhookdevuelve200.
4.6 Que pasa si cambian 3 posts en un solo push
blog-to-n8n.yml recorre cada archivo cambiado en src/content/blog/ y envia un webhook por cada post que cumpla condicion de publicacion.
Resultado:
- 3 archivos publicados cambiados -> 3 eventos -> 3 ejecuciones en n8n -> 3 publicaciones en LinkedIn.
Control de duplicados:
- cada evento lleva
idempotency_key = <slug>:<commit_sha> - puedes guardar esas claves en un store (Data Store n8n, Redis o BD) para bloquear reposts en retries.
Fase 5: Cierre y siguientes iteraciones
5.1 Checklist de cierre de esta fase
- Webhook protegido por secreto compartido
- LinkedIn OAuth funcional en n8n
- Publicacion real confirmada desde evento de GitHub Actions
- Imagen del diagrama final guardada como
heroImagedel post
5.2 Siguientes mejoras recomendadas
- Imagen automatica para el post en LinkedIn:
- generar creatividades por slug/titulo y adjuntar media en la publicacion.
- Soporte para otras categorias:
- extender flujo a proyectos de portfolio y certificaciones.
- Robustez operativa:
- idempotencia persistente
- alertas por error (email, Slack, Telegram)
- metricas de exito/fallo por semana.
¿Aplicamos esta idea en tu contexto?
Si quieres convertir este enfoque en un plan accionable para tu equipo, puedo ayudarte a definir prioridades, riesgos y siguiente iteración.