Saltar al contenido
Gontzal Bilbao
← blog

Auto-publicar el blog en LinkedIn con GitHub Actions y n8n

El MVP con el que cada post nuevo salía en LinkedIn: tras el deploy, GitHub Actions avisa a n8n, que valida un token y publica.

serie · n8n y LinkedIn
Auto-publicar el blog en LinkedIn con GitHub Actions y n8n

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.

idea clave

GitHub Actions solo avisa; n8n decide si publica. Cada lado se cambia sin tocar el otro.

El circuito

  1. Hago push o merge a main con un post en src/content/blog/.
  2. Corre el workflow de despliegue de la web.
  3. Si termina en success, workflow_run lanza Blog -> n8n webhook.
  4. Ese workflow envía un POST a n8n con el token en la cabecera x-webhook-token.
  5. n8n valida el token con un IF, arma el texto y publica en LinkedIn.
  6. 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, como https://tu-tunel.ngrok-free.dev/webhook/blog-linkedin-mvp-56.
  • N8N_WEBHOOK_TOKEN: un secreto largo y aleatorio, el mismo que comprobará n8n.
ojo

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: false explícito. Un post sin el campo cuenta como publicado.
  • En un commit de merge comparo con ^1. Con ~1 a 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:

  1. Webhook: POST, path blog-linkedin-mvp-56 y Respond en Using 'Respond to Webhook' Node.
  2. IF: a la izquierda {{ $json.headers["x-webhook-token"] || "" }}, operador is equal to y a la derecha el valor de N8N_WEBHOOK_TOKEN.
  3. Edit Fields: arma linkedin_text a partir de body.post.
  4. Create a post (nodo LinkedIn): Post As en Person y Text con {{ $json.linkedin_text }}.
  5. Respond to Webhook: 200 con "ok": true en la rama true; 401 con "message": "Unauthorized" en la false.

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.

ojo

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 LinkedIn y Sign In with LinkedIn using OpenID Connect.
  • Scopes: openid, profile, email y w_member_social.
  • Redirect URL: http://localhost:5678/rest/oauth2-credential/callback.
  • En n8n, credencial LinkedIn OAuth2 API con el Client ID y el Client Secret, y Organization Support apagado.

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
ojo

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.

¿Te ha servido?

Si tienes dudas o lo estás montando tú, cuéntamelo.

Escríbeme Compartir en LinkedIn

sigue leyendo

n8n en local con Docker, paso a paso

Instalo n8n en Windows con Docker y persistencia, comparo docker run con Compose y pruebo un primer webhook con un POST real.

25 feb · 4 minautomatización

Pipeline CI/CD en AWS con rollback automático

Un pipeline en AWS que despliega por fases, vigila error rate y latencia, y vuelve solo a la versión anterior si se supera un umbral.

10 ene · 5 minautomatización