El problema
Monté un MVP que publicaba en LinkedIn cada post nuevo del blog, sin abrir LinkedIn. Un push a main desplegaba la web y, si el deploy salía bien, n8n publicaba el post.
Compartir cada post a mano era repetir lo mismo: copiar el título, resumirlo y pegar el enlace. Quería que el blog avisara solo, pero sin que nadie más pudiera publicar en mi nombre.
El n8n es el de n8n en local con Docker, paso a paso, expuesto con un túnel. Aquí no repito la instalación.
GitHub Actions solo avisa; n8n decide si publica. Cada lado se cambia sin tocar el otro.
El circuito
- Hago push o merge a
maincon un post ensrc/content/blog/. - Corre el workflow de despliegue de la web.
- Si termina en
success,workflow_runlanzaBlog -> n8n webhook. - Ese workflow envía un
POSTa n8n con el token en la cabecerax-webhook-token. - n8n valida el token con un
IF, arma el texto y publica en LinkedIn. - n8n responde con un código HTTP, que queda en el log de Actions.
Encadenarlo al deploy evita publicar un enlace que todavía da 404.
Dos secretos en GitHub
En Settings → Secrets and variables → Actions del repo creo:
N8N_WEBHOOK_URL: la URL de producción del webhook, a través del túnel, comohttps://tu-tunel.ngrok-free.dev/webhook/blog-linkedin-mvp-56.N8N_WEBHOOK_TOKEN: un secreto largo y aleatorio, el mismo que comprobará n8n.
Un runner de GitHub no llega a localhost. La URL tiene que ser pública: un túnel, n8n cloud o un servidor propio.
El workflow de GitHub Actions
Esta es una versión resumida del que tenía en el repo. El nombre en workflows tiene que coincidir con el name: del workflow de despliegue:
# .github/workflows/blog-to-n8n.yml
name: Blog -> n8n webhook
on:
workflow_run:
workflows: ["Deploy site"]
types: [completed]
workflow_dispatch:
jobs:
notify-n8n:
if: >-
github.event_name == 'workflow_dispatch' ||
(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 }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Detect published posts and notify n8n
run: |
node <<'EOF'
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const env = process.env;
const sha = env.AFTER_SHA;
const sh = (cmd) => execSync(cmd, { encoding: 'utf8' }).trim();
const unquote = (v) => v.trim().replace(/^["']|["']$/g, '');
const url = env.N8N_WEBHOOK_URL;
if (!url || /localhost|127\.0\.0\.1/.test(url)) {
console.error('N8N_WEBHOOK_URL is missing or points to localhost.');
process.exit(1);
}
// En un merge, comparo con el primer padre para ver todo el PR.
const isMerge = sh(`git rev-list --parents -n 1 ${sha}`).split(' ').length > 2;
const base = isMerge ? `${sha}^1` : `${sha}~1`;
const files = sh(`git diff --name-only ${base} ${sha} -- src/content/blog`)
.split('\n')
.filter((f) => /\.mdx?$/.test(f) && fs.existsSync(f));
async function main() {
for (const file of files) {
const raw = fs.readFileSync(file, 'utf8').replace(/^/, '');
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
const get = (key) =>
unquote(fm.match(new RegExp(`^${key}:(.*)$`, 'm'))?.[1] ?? '');
if (/^false\b/i.test(get('published'))) continue;
const slug = path.basename(file).replace(/\.mdx?$/, '');
const post = {
slug,
title: get('title') || slug,
description: get('description'),
category: get('category'),
tags: get('tags').replace(/^\[|\]$/g, '').split(',')
.map(unquote).filter(Boolean),
highlights: (raw.match(/^#{2,3} .+$/gm) ?? [])
.map((h) => h.replace(/^#+\s*|[`*_]/g, '').trim())
.slice(0, 3),
url: `${env.SITE_URL}/blog/${slug}/`,
};
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-webhook-token': env.N8N_WEBHOOK_TOKEN,
},
body: JSON.stringify({
event: 'blog_published',
source: 'github-actions',
commit_sha: sha,
idempotency_key: `${slug}:${sha}`,
post,
}),
});
if (!res.ok) throw new Error(`Webhook ${res.status}: ${await res.text()}`);
console.log(`Webhook accepted for ${slug}: ${res.status}`);
}
}
main().catch((e) => {
console.error(e.message);
process.exit(1);
});
EOF
Tres decisiones del script:
- Solo salta un
published: falseexplícito. Un post sin el campo cuenta como publicado. - En un commit de merge comparo con
^1. Con~1a secas, el merge de un PR parecía no traer posts. - Envío un evento por post. Si un push trae tres posts, n8n recibe tres llamadas y publica tres veces.
highlights son los tres primeros títulos ## o ### del post. Con ellos el texto de LinkedIn cambia de un post a otro.
El payload
Esto es lo que recibía n8n en el cuerpo del POST (en JSON no caben comentarios):
{
"event": "blog_published",
"source": "github-actions",
"commit_sha": "abc123",
"idempotency_key": "mi-post:abc123",
"post": {
"slug": "mi-post",
"title": "Título del post",
"description": "Descripción",
"category": "Automatizaciones",
"tags": ["n8n", "GitHub Actions"],
"highlights": ["El problema", "El circuito", "El workflow de n8n"],
"url": "https://gontzalbilbao.com/blog/mi-post/"
}
}
idempotency_key combina slug y commit. No llegué a guardarla, pero es la pieza para no publicar dos veces si GitHub reintenta.
El workflow de n8n
Dos ramas, separadas por el IF:
true:Webhook→IF→Edit Fields→Create a post→Respond to Webhook(200).false:IF→Respond to Webhook(401).
Así configuré cada nodo:
Webhook:POST, pathblog-linkedin-mvp-56yRespondenUsing 'Respond to Webhook' Node.IF: a la izquierda{{ $json.headers["x-webhook-token"] || "" }}, operadoris equal toy a la derecha el valor deN8N_WEBHOOK_TOKEN.Edit Fields: armalinkedin_texta partir debody.post.Create a post(nodo LinkedIn):Post AsenPersonyTextcon{{ $json.linkedin_text }}.Respond to Webhook:200con"ok": trueen la ramatrue;401con"message": "Unauthorized"en lafalse.
El token va en cabecera y no en la query string. Así no aparece en la URL ni en los logs que la guardan.
Armar el texto
El campo linkedin_text es una expresión de n8n. Este código va entre {{ y }}:
// n8n · Edit Fields · campo linkedin_text
(() => {
const post = $json.body.post;
const hooks = {
automatizaciones: "Nuevo artículo en mi blog sobre automatización.",
certificaciones: "Nuevo artículo en mi blog sobre certificaciones cloud.",
aws: "Nuevo artículo en mi blog sobre AWS.",
};
const hook = hooks[(post.category || "").toLowerCase().trim()]
|| "Nuevo artículo técnico publicado en mi blog.";
const points = (post.highlights || []).length
? post.highlights
: [post.title, post.description];
return [
hook,
"En este post explico:",
points.map((p) => "- " + p).join("\n"),
"Enlace al artículo 👇\n" + post.url,
].join("\n\n");
})()
La imagen no la subía n8n. LinkedIn lee las etiquetas Open Graph del enlace y muestra la portada del post. No lee SVG, así que el sitio publica también un PNG para Open Graph.
El enlace tiene que ir en el cuerpo del post. Si va en un comentario, la publicación sale sin vista previa ni imagen.
La conexión con LinkedIn
LinkedIn pide una app de desarrollador asociada a una página de empresa. Creé una página mínima solo para eso.
- En
LinkedIn Developer → My Apps → Create app, la app asociada a la página. - Productos:
Share on LinkedInySign In with LinkedIn using OpenID Connect. - Scopes:
openid,profile,emailyw_member_social. - Redirect URL:
http://localhost:5678/rest/oauth2-credential/callback. - En n8n, credencial
LinkedIn OAuth2 APIcon el Client ID y el Client Secret, yOrganization Supportapagado.
Exponer n8n con un túnel
GitHub tiene que llegar a mi n8n local. Usé ngrok; cloudflared tunnel --url http://localhost:5678 hace lo mismo.
# PowerShell
winget install ngrok.ngrok
ngrok config add-authtoken <TU_NGROK_AUTHTOKEN>
ngrok http 5678
ngrok muestra una URL https://…ngrok-free.dev. Esa URL más /webhook/blog-linkedin-mvp-56 es el valor de N8N_WEBHOOK_URL.
Validar el token
Antes del primer push, comprobé las dos ramas contra la URL de producción, con el workflow publicado:
# PowerShell: el token decide la rama del IF
$uri = "http://localhost:5678/webhook/blog-linkedin-mvp-56"
$body = @{
event = "blog_published"
idempotency_key = "manual-001"
post = @{
slug = "demo-post"
title = "Demo post para n8n"
category = "automatizaciones"
url = "https://gontzalbilbao.com/blog/demo-post/"
}
} | ConvertTo-Json -Depth 5
function Test-Token($token) {
$headers = @{}
if ($token) { $headers["x-webhook-token"] = $token }
try {
$r = Invoke-WebRequest -UseBasicParsing -Method Post -Uri $uri `
-Headers $headers -ContentType "application/json" -Body $body -ErrorAction Stop
"STATUS: $($r.StatusCode)"
} catch {
"STATUS: $($_.Exception.Response.StatusCode.value__)"
}
}
Test-Token "" # esperado: 401
Test-Token "SECRETO_INVALIDO" # esperado: 401
Test-Token $env:N8N_WEBHOOK_TOKEN # esperado: 200
La llamada con el token bueno publica de verdad en LinkedIn. Conviene hacerla una vez y borrar el post de prueba.
Después, el circuito completo: edito un post, hago push a main y miro tres sitios. En Actions, el deploy y luego Webhook accepted for <slug>: 200. En n8n, la ejecución por la rama true. Y el post en LinkedIn.
Si falla, el código lo dice casi todo: 401 es un token desalineado, 404 un path distinto o el workflow sin publicar, y 5xx suele ser la credencial de LinkedIn.
Lo que aprendí
Lo que más me costó no fue LinkedIn, sino el diff de los merges y el orden con el deploy. Hoy el flujo está apagado a propósito: publicar a mano me deja elegir el momento y escribir un texto para cada post.
Si tienes dudas o lo estás montando tú, cuéntamelo.