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:

  1. Como detectar en GitHub Actions que hay un post nuevo o actualizado bajo src/content/blog/.
  2. Como enviar un payload estandar a n8n con datos utiles del post.
  3. Como proteger el webhook con un secreto compartido para que no publique cualquiera.
  4. Como transformar los datos en un texto listo para LinkedIn.
  5. Como conectar LinkedIn OAuth en n8n y publicar en tu perfil personal.
  6. 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

  1. Cambia un post en src/content/blog/.
  2. Haces push o merge a main.
  3. Se ejecuta Deploy Astro to Hostinger.
  4. Si el deploy termina en success, se ejecuta Blog -> n8n webhook via workflow_run.
  5. GitHub Actions envia POST al webhook de n8n.
  6. n8n valida token, transforma el payload y publica en LinkedIn.
  7. n8n responde con estado HTTP y deja traza en Executions.

Prerrequisitos

  • n8n local 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_URL debe ser publico (ngrok, Cloudflare Tunnel, n8n cloud o VPS).

Fase 1: GitHub Actions (detectar y notificar)

1.1 Secretos del repositorio

En GitHub crea:

  1. N8N_WEBHOOK_URL
  • Ejemplo: https://tu-tunel.ngrok-free.dev/webhook/blog-linkedin-autopost
  1. N8N_WEBHOOK_TOKEN
  • Secreto compartido que validara el nodo IF en n8n.

1.2 Trigger real usado en esta guia

Archivo: .github/workflows/blog-to-n8n.yml

Trigger configurado:

  • workflow_run del workflow Deploy Astro to Hostinger con estado completed
  • se ejecuta solo cuando ese deploy acaba en success y la rama es main
  • workflow_dispatch manual disponible

Orden de ejecucion en produccion:

  1. push a main
  2. corre Deploy Astro to Hostinger
  3. si deploy = success, corre Blog -> n8n webhook
  4. 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..HEAD para 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:

  • event y source permiten filtrar en n8n.
  • idempotency_key ayuda a controlar duplicados.
  • commit_sha y repo facilitan trazabilidad.
  • post.highlights sale del propio markdown (primeros ##/###) para enriquecer el copy.
  • post.url llega 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_run del deploy (Deploy Astro to Hostinger)
  • se ejecuta solo con deploy success en rama main
  • workflow_dispatch para ejecucion manual
  • validacion de secreto N8N_WEBHOOK_URL
  • bloqueo de localhost en 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:

  • Webhook desacopla GitHub Actions de LinkedIn.
  • IF evita publicaciones no autorizadas.
  • Edit Fields normaliza entradas y evita depender del formato bruto.
  • Create a post concentra la integracion con LinkedIn.
  • Respond to Webhook devuelve estado claro para depurar desde Actions.

2.2 Nodo Webhook

  • Method: POST
  • Path: blog-linkedin-mvp-56 (o blog-linkedin-autopost si 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 200 en cada llamada valida
  • ejecucion true en n8n (no rama false)
  • Create a post sin 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:

  1. Crea una Page minima desde LinkedIn.
  2. Campos recomendados:
  • Nombre: Gontzal Bilbao Tech
  • URL publica: gontzal-bilbao-tech
  • Sitio web: https://gontzalbilbao.com
  • Industria: tecnologia / software
  • Tamano: 1-10
  • Tipo: Self-employed o Privately held
  • Logo y lema breves

3.5 Crear App en LinkedIn Developer y credencial OAuth en n8n

  1. LinkedIn Developer -> My Apps -> Create app
  • Asocia la app a la Page creada.
  • Completa nombre, logo y URL de privacidad.
  1. Auth -> OAuth 2.0 settings
  • En Authorized redirect URLs anade exactamente:
  • http://localhost:5678/rest/oauth2-credential/callback
  1. 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:
  • openid
  • profile
  • email
  • w_member_social
  1. En n8n, crea credencial LinkedIn OAuth2 API
  • Pega Client ID y Client 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):

  1. Instalar:
winget install ngrok.ngrok
  1. Configurar token de ngrok (desde tu dashboard):
ngrok config add-authtoken TU_NGROK_AUTHTOKEN_REAL
  1. Levantar tunel a n8n local:
ngrok http 5678
  1. 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-56
  • N8N_WEBHOOK_TOKEN = el mismo secreto del nodo IF

Tambien verifica:

  • Workflow n8n publicado y activo.
  • Nodo Webhook con path correcto (blog-linkedin-mvp-56).

4.3 Forzar evento de blog

  1. Edita un post publicado en src/content/blog/.
  2. Haz commit y push a main (o merge de PR a main).
  3. Revisa en GitHub Actions que termine primero Deploy Astro to Hostinger.
  4. Revisa despues la ejecucion de Blog -> n8n webhook (job notify-n8n).
  5. Revisa en n8n Executions.
  6. 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:

  • 401 suele ser secreto desalineado (N8N_WEBHOOK_TOKEN vs nodo IF)
  • 404 suele ser path de webhook incorrecto o workflow n8n no activo
  • 5xx suele ser error interno del flujo o credenciales LinkedIn
  • No changed blog markdown files. Nothing to notify. significa que ese commit no trae cambios en src/content/blog/**

4.5 Verificar ejecucion en n8n

En Executions, valida:

  1. Webhook recibe el payload esperado.
  2. IF pasa por rama true con secreto correcto.
  3. Edit Fields genera linkedin_text.
  4. Create a post devuelve success.
  5. Respond to Webhook devuelve 200.

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 heroImage del post

5.2 Siguientes mejoras recomendadas

  1. Imagen automatica para el post en LinkedIn:
  • generar creatividades por slug/titulo y adjuntar media en la publicacion.
  1. Soporte para otras categorias:
  • extender flujo a proyectos de portfolio y certificaciones.
  1. 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.