{
  "name": "GitHub Release Watcher",
  "nodes": [
    {
      "id": "a1b2c3d4",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        64,
        768
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "b2c3d4e5",
      "name": "Build Repo Watchlist",
      "type": "n8n-nodes-base.code",
      "position": [
        480,
        592
      ],
      "parameters": {
        "jsCode": "// Build repo watchlist \u2014 edit repos and registries below\n// Scalar config (token, channels, alerts) lives in \"Configure Watcher\" node\nconst config = $('Configure Watcher').first().json;\n\n// === GITHUB REPOS ===\nconst repos = [\n  { owner: 'immich-app', repo: 'immich', label: 'Immich', category: 'media', source: 'github', dependsOn: ['postgres', 'redis'] },\n  { owner: 'jellyfin', repo: 'jellyfin', label: 'Jellyfin', category: 'media', source: 'github', dependsOn: [] },\n  { owner: 'dani-garcia', repo: 'vaultwarden', label: 'Vaultwarden', category: 'security', source: 'github', dependsOn: [] },\n  { owner: 'traefik', repo: 'traefik', label: 'Traefik', category: 'networking', source: 'github', dependsOn: [] },\n  { owner: 'louislam', repo: 'uptime-kuma', label: 'Uptime Kuma', category: 'monitoring', source: 'github', dependsOn: [] },\n  { owner: 'n8n-io', repo: 'n8n', label: 'n8n', category: 'automation', source: 'github', dependsOn: ['postgres', 'redis'] },\n  { owner: 'containrrr', repo: 'watchtower', label: 'Watchtower', category: 'management', source: 'github', dependsOn: [] },\n  { owner: 'nextcloud', repo: 'server', label: 'Nextcloud', category: 'productivity', source: 'github', dependsOn: ['postgres', 'redis'] },\n  { owner: 'grafana', repo: 'grafana', label: 'Grafana', category: 'monitoring', source: 'github', dependsOn: [] },\n  { owner: 'prometheus', repo: 'prometheus', label: 'Prometheus', category: 'monitoring', source: 'github', dependsOn: [] },\n];\n\n// === CONTAINER REGISTRIES (Docker Hub / GHCR) ===\nconst registries = [\n  { registry: 'dockerhub', namespace: 'linuxserver', image: 'heimdall', label: 'Heimdall (Docker)', category: 'management', source: 'dockerhub', dependsOn: [] },\n  { registry: 'dockerhub', namespace: 'linuxserver', image: 'wireguard', label: 'WireGuard (Docker)', category: 'networking', source: 'dockerhub', dependsOn: [] },\n];\n\n// === ALERT OVERRIDES (per-repo) ===\n// Default alert rules come from Configure Watcher node\nvar defaultChannels = config.default_channels ? config.default_channels.split(',').map(function(c) { return c.trim(); }) : ['discord'];\nvar defaultAlertRules = {\n  urgencyOverride: null,\n  channels: defaultChannels,\n  instantAlert: config.default_instant_alert === true\n};\n\nvar alertOverrides = {\n  'dani-garcia/vaultwarden': { urgencyOverride: null, channels: ['discord', 'telegram', 'ntfy'], instantAlert: true },\n  'traefik/traefik': { urgencyOverride: null, channels: ['discord', 'telegram'], instantAlert: true },\n};\n\n// Build output items\nvar ghItems = repos.map(function(r) {\n  return {\n    json: {\n      owner: r.owner, repo: r.repo, label: r.label,\n      category: r.category, source: r.source, dependsOn: r.dependsOn,\n      _githubToken: config.github_token || '',\n      alertRules: alertOverrides[r.owner + '/' + r.repo] || defaultAlertRules,\n    }\n  };\n});\n\nvar regItems = registries.map(function(r) {\n  var repoKey = r.registry + ':' + r.namespace + '/' + r.image;\n  var fetchUrl, fetchHeaders;\n  if (r.registry === 'dockerhub') {\n    fetchUrl = 'https://hub.docker.com/v2/repositories/' + r.namespace + '/' + r.image + '/tags?page_size=5&ordering=last_updated';\n    fetchHeaders = {};\n  } else if (r.registry === 'ghcr') {\n    fetchUrl = 'https://ghcr.io/v2/' + r.namespace + '/' + r.image + '/tags/list';\n    fetchHeaders = {};\n  }\n  return {\n    json: {\n      owner: r.namespace, repo: r.image, label: r.label,\n      category: r.category, source: r.source, registry: r.registry,\n      namespace: r.namespace, image: r.image,\n      repoKey: repoKey, fetchUrl: fetchUrl, fetchHeaders: fetchHeaders,\n      dependsOn: r.dependsOn,\n      alertRules: alertOverrides[repoKey] || defaultAlertRules,\n    }\n  };\n});\n\nreturn [...ghItems, ...regItems];"
      },
      "typeVersion": 2
    },
    {
      "id": "c3d4e5f6",
      "name": "Fetch Latest Release",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        2048,
        624
      ],
      "parameters": {
        "url": "=https://api.github.com/repos/{{ $json.owner }}/{{ $json.repo }}/releases/latest",
        "options": {},
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "n8n-release-watcher/1.0"
            },
            {
              "name": "Accept",
              "value": "application/vnd.github+json"
            },
            {
              "name": "=Authorization",
              "value": "={{ $json._githubToken ? 'Bearer ' + $json._githubToken : '' }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "d4e5f6a7",
      "name": "Extract Release Data",
      "type": "n8n-nodes-base.code",
      "position": [
        2240,
        624
      ],
      "parameters": {
        "jsCode": "const repos = $('Deduplicate Watchlist').all().filter(function(i) { return i.json.source === 'github'; });\nconst responses = $input.all();\nconst results = [];\nlet rateLimited = false;\n\nfor (let i = 0; i < repos.length; i++) {\n  const repo = repos[i].json;\n  const res = responses[i] ? responses[i].json : {};\n\n  if (res.message && res.message.includes('rate limit')) {\n    rateLimited = true;\n    continue;\n  }\n\n  if (!res.tag_name) continue;\n  if (res.prerelease === true) continue;\n\n  results.push({\n    json: {\n      owner: repo.owner,\n      repo: repo.repo,\n      label: repo.label,\n      category: repo.category,\n      source: 'github',\n      tagName: res.tag_name,\n      releaseName: res.name || res.tag_name,\n      publishedAt: res.published_at,\n      htmlUrl: res.html_url,\n      changelog: res.body || 'No release notes provided.',\n      alertRules: repo.alertRules,\n      dependsOn: repo.dependsOn || [],\n    }\n  });\n}\n\nif (rateLimited && results.length === 0) {\n  return [{ json: { _rateLimited: true, _error: 'GitHub API rate limit exceeded. Add a GitHub token to the Repo Watchlist node for 5,000 requests/hour.' } }];\n}\n\nif (results.length === 0) {\n  return [{ json: { _noReleases: true } }];\n}\n\nif (rateLimited) {\n  results[0].json._rateLimitWarning = 'Some repos were rate limited. Add a token for reliable monitoring.';\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "e5f6a7b8",
      "name": "Compare Versions",
      "type": "n8n-nodes-base.code",
      "position": [
        2704,
        640
      ],
      "parameters": {
        "jsCode": "// --- TEST MODE ---\nconst config = $('Configure Watcher').first().json;\nconst items = $input.all();\nif (config.test_mode === true) {\n  const testItems = items.filter(i => !i.json._noReleases && !i.json._rateLimited && !i.json._noRegistryReleases && i.json.tagName);\n  if (testItems.length > 0) {\n    const d = testItems[0].json;\n    const out = Object.assign({}, d);\n    out.previousTag = '0.0.0-test';\n    out._storageKey = d.source === 'github' ? d.owner + '/' + d.repo : d.source + ':' + d.owner + '/' + d.repo;\n    out._testMode = true;\n    return [{ json: out }];\n  }\n}\n\n// --- NORMAL MODE ---\nconst staticData = $getWorkflowStaticData('global');\nconst newReleases = [];\nconst isFirstRun = !staticData.initialized;\n\nfunction normalizeTag(tag) {\n  if (!tag) return '';\n  return String(tag).trim().replace(/^v/i, '').toLowerCase();\n}\n\nfor (const item of items) {\n  const d = item.json;\n  if (d._noReleases || d._rateLimited || d._noRegistryReleases) continue;\n\n  // Use source-aware key for registry items\n  const key = d.source === 'github'\n    ? d.owner + '/' + d.repo\n    : d.source + ':' + d.owner + '/' + d.repo;\n  const currentTag = normalizeTag(d.tagName);\n  const storedTag = normalizeTag(staticData[key]);\n\n  if (isFirstRun) {\n    staticData[key] = d.tagName;\n    continue;\n  }\n\n  if (storedTag === currentTag) continue;\n\n  const out = Object.assign({}, d);\n  out.previousTag = staticData[key] || null;\n  out._storageKey = key;\n  newReleases.push({ json: out });\n}\n\nif (isFirstRun) {\n  staticData.initialized = true;\n  const seeded = items.filter(function(i) { return !i.json._noReleases && !i.json._rateLimited && !i.json._noRegistryReleases; }).length;\n  return [{ json: { _firstRun: true, reposSeeded: seeded } }];\n}\n\nif (newReleases.length === 0) {\n  const warn = items.find(function(i) { return i.json._rateLimitWarning; });\n  const warnMsg = warn ? warn.json._rateLimitWarning : null;\n  return [{ json: { _noUpdates: true, _rateLimitWarning: warnMsg } }];\n}\n\nreturn newReleases;"
      },
      "typeVersion": 2
    },
    {
      "id": "f6a7b8c9",
      "name": "Has Updates?",
      "type": "n8n-nodes-base.if",
      "position": [
        2864,
        640
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "check-tag",
              "operator": {
                "type": "string",
                "operation": "exists"
              },
              "leftValue": "={{ $json.tagName }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "a7b8c9d0",
      "name": "Prep Changelog for AI",
      "type": "n8n-nodes-base.code",
      "position": [
        3104,
        672
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n  const d = item.json;\n  let log = d.changelog || '';\n\n  // Preserve breaking changes section\n  let breakingSection = '';\n  const bMatch = log.match(/(?:#+\\s*)?(?:BREAKING|Breaking Changes?|\\u26a0\\ufe0f)[\\s\\S]*?(?=\\n#{1,3}\\s|\\n\\n---|\\n\\n\\n|$)/i);\n  if (bMatch) {\n    breakingSection = '\\n\\n--- BREAKING CHANGES ---\\n' + bMatch[0].trim();\n  }\n\n  const maxLen = 1500 - breakingSection.length;\n  if (log.length > maxLen) {\n    log = log.substring(0, maxLen).trim() + '\\n\\n[...truncated]';\n  }\n\n  if (breakingSection && log.indexOf('BREAKING') === -1) {\n    log += breakingSection;\n  }\n\n  results.push({ json: { ...d, changelogTruncated: log } });\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "b8c9d0e1",
      "name": "Claude Haiku",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "position": [
        3344,
        864
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-haiku-4-5-20251001"
        },
        "options": {
          "temperature": 0.2,
          "maxTokensToSample": 1024
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "c9d0e1f2",
      "name": "Analyze Release",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        3296,
        672
      ],
      "parameters": {
        "text": "=Software: {{ $json.label }} ({{ $json.owner }}/{{ $json.repo }})\nVersion: {{ $json.tagName }}{{ $json.previousTag ? ' (previous: ' + $json.previousTag + ')' : '' }}\nSource: {{ $json.source || 'github' }}\nRelease date: {{ $json.publishedAt || 'unknown' }}\n\nRelease notes:\n{{ $json.changelogTruncated }}",
        "batching": {},
        "messages": {
          "messageValues": [
            {
              "message": "You are a self-hosted software update analyst. Summarize release notes for a homelabber.\n\nFor each release, provide:\n1. A 1-2 sentence summary of what changed (facts only, no marketing)\n2. Whether there are BREAKING CHANGES (yes/no)\n3. If breaking: what specifically breaks and migration steps\n4. Update urgency: \"critical\" (security fix, CVE), \"recommended\" (useful features/bugfixes), or \"optional\" (minor/cosmetic)\n5. Security assessment: flag any CVEs, security patches, vulnerability fixes, or security-related keywords\n6. Top 3 most impactful changes as a brief list\n7. Specific migration/upgrade steps needed (empty string if none)\n\nReturn ONLY valid JSON:\n{\n  \"summary\": \"1-2 sentence summary\",\n  \"breaking\": false,\n  \"breakingDetails\": null,\n  \"urgency\": \"recommended\",\n  \"security\": false,\n  \"securityDetails\": null,\n  \"keyChanges\": [\"change1\", \"change2\", \"change3\"],\n  \"migrationNotes\": \"\"\n}\n\nSecurity rules:\n- If changelog mentions CVE-XXXX-YYYY, set security:true and include CVE numbers in securityDetails\n- If changelog mentions \"security fix\", \"vulnerability\", \"patch\", \"XSS\", \"SQL injection\", \"auth bypass\", set security:true\n- Security issues automatically make urgency \"critical\" unless they are minor/low-severity\n\nBe concise. Homelabbers want facts, not marketing language."
            }
          ]
        },
        "promptType": "define"
      },
      "typeVersion": 1.7
    },
    {
      "id": "d0e1f2a3",
      "name": "Parse AI Response",
      "type": "n8n-nodes-base.code",
      "position": [
        3616,
        672
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst repos = $('Prep Changelog for AI').all();\nconst results = [];\n\nfor (let i = 0; i < items.length; i++) {\n  const aiText = items[i].json.text || items[i].json.output || '';\n  const repoData = repos[i] ? repos[i].json : {};\n\n  let analysis;\n  try {\n    const jsonMatch = aiText.match(/\\{[\\s\\S]*\\}/);\n    analysis = jsonMatch ? JSON.parse(jsonMatch[0]) : null;\n  } catch (e) {\n    analysis = null;\n  }\n\n  if (!analysis) {\n    analysis = {\n      summary: aiText.substring(0, 200),\n      breaking: false,\n      breakingDetails: null,\n      urgency: 'optional',\n      security: false,\n      securityDetails: null,\n      keyChanges: [],\n      migrationNotes: ''\n    };\n  }\n\n  // If security detected and urgency not already critical, escalate\n  if (analysis.security === true && analysis.urgency !== 'critical') {\n    analysis.urgency = 'critical';\n  }\n\n  results.push({\n    json: {\n      owner: repoData.owner,\n      repo: repoData.repo,\n      label: repoData.label,\n      category: repoData.category,\n      source: repoData.source || 'github',\n      tagName: repoData.tagName,\n      previousTag: repoData.previousTag || null,\n      htmlUrl: repoData.htmlUrl,\n      publishedAt: repoData.publishedAt,\n      changelog: repoData.changelog || repoData.changelogTruncated || '',\n      summary: analysis.summary,\n      breaking: analysis.breaking === true,\n      breakingDetails: analysis.breakingDetails || null,\n      urgency: analysis.urgency || 'optional',\n      security: analysis.security === true,\n      securityDetails: analysis.securityDetails || null,\n      keyChanges: analysis.keyChanges || [],\n      migrationNotes: analysis.migrationNotes || '',\n      alertRules: repoData.alertRules || { channels: ['discord', 'telegram'], instantAlert: false },\n      dependsOn: repoData.dependsOn || [],\n      _storageKey: repoData._storageKey || (repoData.owner + '/' + repoData.repo),\n    }\n  });\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "e1f2a3b4",
      "name": "Format Digest",
      "type": "n8n-nodes-base.code",
      "position": [
        4272,
        784
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst _cfg = $('Configure Watcher').first().json;\nconst allWatchlist = $('Deduplicate Watchlist').all();\nconst repoCount = allWatchlist.length;\nconst now = new Date();\nconst dateStr = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });\n\nconst order = { critical: 0, recommended: 1, optional: 2 };\nconst sorted = [...items].sort(function(a, b) {\n  const ao = order[a.json.finalUrgency || a.json.urgency] ?? 2;\n  const bo = order[b.json.finalUrgency || b.json.urgency] ?? 2;\n  if (ao !== bo) return ao - bo;\n  return (a.json.label || '').localeCompare(b.json.label || '');\n});\n\nconst emoji = { critical: '\\ud83d\\udd34', recommended: '\\ud83d\\udfe0', optional: '\\ud83d\\udd35' };\nconst lbl = { critical: 'CRITICAL', recommended: 'RECOMMENDED', optional: 'OPTIONAL' };\n\nconst dependents = {};\nfor (const w of allWatchlist) {\n  var deps = w.json.dependsOn || [];\n  for (var di = 0; di < deps.length; di++) {\n    if (!dependents[deps[di]]) dependents[deps[di]] = [];\n    dependents[deps[di]].push(w.json.label || w.json.repo);\n  }\n}\n\nconst dockerUpdateMap = {\n  'immich-app/immich': 'docker compose pull && docker compose up -d',\n  'dani-garcia/vaultwarden': 'docker pull vaultwarden/server:TAG && docker compose up -d',\n  'traefik/traefik': 'docker pull traefik:TAG && docker compose up -d',\n  'louislam/uptime-kuma': 'docker pull louislam/uptime-kuma:TAG && docker compose up -d',\n  'n8n-io/n8n': 'docker pull n8nio/n8n:TAG && docker compose up -d',\n  'grafana/grafana': 'docker pull grafana/grafana:TAG && docker compose up -d',\n  'containrrr/watchtower': 'docker pull containrrr/watchtower:TAG && docker compose up -d',\n};\n\nfunction getUpdateCommand(d) {\n  if (d.source === 'dockerhub') return 'docker pull ' + (d.namespace || d.owner) + '/' + (d.image || d.repo) + ':' + d.tagName + ' && docker compose up -d';\n  if (d.source === 'ghcr') return 'docker pull ghcr.io/' + (d.namespace || d.owner) + '/' + (d.image || d.repo) + ':' + d.tagName + ' && docker compose up -d';\n  var key = d.owner + '/' + d.repo;\n  var cmd = dockerUpdateMap[key];\n  if (cmd) return cmd.replace(/TAG/g, d.tagName);\n  return '# Check ' + key + ' release notes for upgrade instructions';\n}\n\nconst counts = { critical: 0, recommended: 0, optional: 0 };\nfor (var ci = 0; ci < sorted.length; ci++) {\n  var urg = sorted[ci].json.finalUrgency || sorted[ci].json.urgency || 'optional';\n  counts[urg] = (counts[urg] ?? 0) + 1;\n}\n\nvar text = '\\ud83d\\udce6 Stack Update Digest \\u2014 ' + dateStr + '\\n';\ntext += repoCount + ' sources checked, ' + sorted.length + ' update' + (sorted.length !== 1 ? 's' : '') + ' found.\\n\\n';\n\nvar slackText = '*\\ud83d\\udce6 Stack Update Digest* \\u2014 ' + dateStr + '\\n' + repoCount + ' sources checked, ' + sorted.length + ' updates\\n\\n';\nvar embeds = [];\n\nfor (var si = 0; si < sorted.length; si++) {\n  var d = sorted[si].json;\n  var urgency = d.finalUrgency || d.urgency || 'optional';\n  var e = emoji[urgency] ?? '\\ud83d\\udd35';\n  var l = lbl[urgency] ?? 'OPTIONAL';\n  var sourceTag = d.source !== 'github' ? ' [' + d.source.toUpperCase() + ']' : '';\n  var updateCmd = getUpdateCommand(d);\n\n  text += e + ' ' + l + ': ' + d.label + ' ' + d.tagName + sourceTag;\n  if (d.previousTag) text += ' (was ' + d.previousTag + ')';\n  text += '\\n   ' + d.summary + '\\n';\n  if (d.breaking && d.breakingDetails) text += '   \\u26a0\\ufe0f BREAKING: ' + d.breakingDetails + '\\n';\n  if (d.security && d.securityDetails) text += '   \\ud83d\\udee1\\ufe0f SECURITY: ' + d.securityDetails + '\\n';\n  if (d.keyChanges && d.keyChanges.length > 0) text += '   Key changes: ' + d.keyChanges.join('; ') + '\\n';\n  if (d.migrationNotes) text += '   \\ud83d\\udccb Migration: ' + d.migrationNotes + '\\n';\n  text += '   \\ud83d\\udcbb `' + updateCmd + '`\\n';\n  var repoName = d.repo || d.image;\n  if (dependents[repoName] && dependents[repoName].length > 0) {\n    text += '   \\ud83d\\udd17 Heads up: ' + dependents[repoName].join(', ') + ' depend on ' + repoName + '\\n';\n  }\n  text += '   \\u2192 ' + d.htmlUrl + '\\n\\n';\n\n  slackText += e + ' *' + l + ': ' + d.label + ' ' + d.tagName + '*' + sourceTag + '\\n';\n  slackText += d.summary + '\\n';\n  if (d.security) slackText += '\\ud83d\\udee1\\ufe0f ' + (d.securityDetails || 'Security update') + '\\n';\n  slackText += '```' + updateCmd + '```\\n<' + d.htmlUrl + '|Release Notes>\\n\\n';\n\n  var color = urgency === 'critical' ? 16711680 : urgency === 'recommended' ? 16750848 : 3381503;\n  var desc = d.summary;\n  if (d.breaking) desc += '\\n\\n\\u26a0\\ufe0f **BREAKING:** ' + (d.breakingDetails || 'See release notes');\n  if (d.security) desc += '\\n\\n\\ud83d\\udee1\\ufe0f **SECURITY:** ' + (d.securityDetails || 'Security-related');\n  if (d.keyChanges && d.keyChanges.length > 0) desc += '\\n\\n**Key changes:** ' + d.keyChanges.join(', ');\n  desc += '\\n\\n```\\n' + updateCmd + '\\n```';\n\n  embeds.push({\n    title: e + ' ' + d.label + ' ' + d.tagName + sourceTag,\n    description: d.previousTag ? desc + '\\n\\nPrevious: ' + d.previousTag : desc,\n    url: d.htmlUrl,\n    color: color,\n    footer: { text: l + (d.security ? ' | SECURITY' : '') },\n  });\n}\n\nvar parts = [];\nif (counts.critical > 0) parts.push(counts.critical + ' critical');\nif (counts.recommended > 0) parts.push(counts.recommended + ' recommended');\nif (counts.optional > 0) parts.push(counts.optional + ' optional');\ntext += '\\u2014 ' + parts.join(', ') + '.';\n\nreturn [{\n  json: {\n    digest: text,\n    discordPayload: {\n      content: '\\ud83d\\udce6 **Stack Update Digest** \\u2014 ' + dateStr + ' (' + repoCount + ' sources checked)',\n      embeds: embeds,\n    },\n    telegramMessage: text,\n    slackText: slackText,\n    ntfyPayload: { topic: (_cfg.ntfy_topic || 'release-watcher'), title: 'Stack Update Digest \\u2014 ' + dateStr, message: sorted.length + ' updates: ' + parts.join(', '), priority: counts.critical > 0 ? 4 : 3, tags: counts.critical > 0 ? ['package', 'warning'] : ['package'] },\n    updateCount: sorted.length,\n    urgencyCounts: counts,\n    updates: sorted.map(function(s) {\n      return { key: s.json._storageKey || (s.json.owner + '/' + s.json.repo), tag: s.json.tagName };\n    }),\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "f2a3b4c5",
      "name": "Send Discord",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        4800,
        576
      ],
      "parameters": {
        "url": "={{ $('Configure Watcher').first().json.discord_webhook_url }}",
        "body": "={{ JSON.stringify($json.discordPayload) }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "raw",
        "sendHeaders": true,
        "rawContentType": "application/json",
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "n8n-release-watcher/1.0"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "a3b4c5d6",
      "name": "Send Telegram",
      "type": "n8n-nodes-base.telegram",
      "onError": "continueRegularOutput",
      "position": [
        4800,
        720
      ],
      "parameters": {
        "text": "={{ $json.telegramMessage }}",
        "chatId": "={{ $('Configure Watcher').first().json.telegram_chat_id }}",
        "additionalFields": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "b4c5d6e7",
      "name": "Update Stored Versions",
      "type": "n8n-nodes-base.code",
      "position": [
        4800,
        1216
      ],
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst updates = $input.first().json.updates || [];\n\nfor (const u of updates) {\n  staticData[u.key] = u.tag;\n}\n\nreturn [{ json: { versionsUpdated: updates.length, versions: updates } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "s1000001",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -544,
        192
      ],
      "parameters": {
        "width": 512,
        "height": 752,
        "content": "# \ud83d\udce6 GitHub Release Watcher\n\nMonitors GitHub repos and container registries (Docker Hub, GHCR) for new releases. Uses Claude AI to summarize changelogs, flag breaking changes, detect CVEs, and rate urgency. Delivers color-coded digests to Discord, Telegram, Slack, and ntfy.\n\n### How it works\n1. Runs daily at 8 AM (or click **Test workflow** to run now)\n2. Reads your settings from the **Configure Watcher** node\n3. Builds a watchlist from your manual repos + optional docker-compose auto-detection\n4. Checks GitHub Releases API + Docker Hub/GHCR for each repo\n5. Compares against stored versions to find what\u2019s new\n6. Claude Haiku AI reads each changelog and rates urgency\n7. Critical items get instant alerts; everything else batches into a daily digest\n8. Sends to your enabled channels (Discord, Telegram, Slack, ntfy)\n9. Saves all releases to PostgreSQL and updates stored versions\n\n### Quick Start\n1. Add your **Anthropic API key** \u2192 [Setup guide](https://www.nxsi.io/guides/anthropic-api-key)\n2. Open **Configure Watcher** \u2192 set your channel URLs (see \u2699\ufe0f Config sticky)\n3. Open **Build Repo Watchlist** \u2192 add/remove repos to monitor\n4. Click **Test workflow** \u2014 test mode is ON by default, so you\u2019ll see a full sample alert through the entire pipeline (AI analysis, formatting, channel delivery)\n5. Once verified, set `test_mode` to `false` in **Configure Watcher**\n6. Toggle **Active** \u2192 runs daily at 8 AM. Alerts fire only when repos publish new releases, so your first real alert may take days or weeks\n\n\ud83d\udcd6 [Prerequisites & API key reference](https://www.nxsi.io/guides)"
      },
      "typeVersion": 1
    },
    {
      "id": "s2000001",
      "name": "Config Section",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 620,
        "height": 1036,
        "content": "## \u2699\ufe0f Configuration\n\nOpen **Configure Watcher** to set everything:\n\n**\ud83d\udd11 GitHub Token** (optional but recommended)\nPersonal access token for higher API rate limits. Without one you get 60 requests/hour; with one you get 5,000. [Setup guide](https://www.nxsi.io/guides/github-personal-access-token)\n\n**\ud83d\udcec Delivery Channels** \u2014 enable the ones you use:\n\u2022 **Discord** \u2192 paste your webhook URL ([How to create one](https://www.nxsi.io/guides/discord-webhook))\n\u2022 **Telegram** \u2192 paste your chat ID ([Setup guide](https://www.nxsi.io/guides/telegram-bot))\n\u2022 **Slack** \u2192 paste your channel ID ([Setup guide](https://www.nxsi.io/guides/slack-bot-token))\n\u2022 **ntfy** \u2192 free push notifications to your phone ([Setup guide](https://www.nxsi.io/guides/ntfy)). The **topic** is just a unique name you pick (e.g. `my-homelab-updates`).\n\n**\ud83d\udc33 Auto-Detection** (optional)\n\u2022 `enable_auto_detect` \u2192 set `true` to scan a docker-compose file\n\u2022 `compose_url` \u2192 a direct link to your raw docker-compose.yml (e.g. a GitHub raw URL like `https://raw.githubusercontent.com/you/repo/main/docker-compose.yml`)\n\u2022 `compose_content` \u2192 OR paste your entire docker-compose.yml text here instead of a URL\n\n**\ud83d\udd14 Default Alert Behavior**\n\u2022 `default_channels` \u2192 comma-separated list (e.g. `discord,telegram`)\n\u2022 `default_instant_alert` \u2192 `false` (default) = only critical/security updates send immediately, everything else batches into one daily digest. Set `true` to send every update immediately.\n\n**\ud83e\uddea Test Mode**\n\u2022 `test_mode` \u2192 `true` (default) = click **Test workflow** to see a full sample alert without waiting for a real release. Set `false` once your channels are verified.\n\nEdit **Build Repo Watchlist** to add repos and set per-repo overrides."
      },
      "typeVersion": 1
    },
    {
      "id": "s3000001",
      "name": "Detection Section",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1776,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 1236,
        "height": 556,
        "content": "## \ud83d\udd0d Release Detection\n\nFetches the latest release from each repo:\n\u2022 **GitHub repos** \u2192 GitHub Releases API (gets full changelogs)\n\u2022 **Docker Hub / GHCR** \u2192 container tag APIs (gets latest stable tag)\n\nPre-releases and RC/beta tags are automatically filtered out. Results from both sources merge together, then **Compare Versions** checks each against the last known version stored in workflow static data.\n\n**Test mode** (`test_mode: true`)**:** Bypasses version comparison entirely \u2014 forces one repo through the full alert pipeline so you can verify everything works on demand.\n\n**Production mode** (`test_mode: false`)**:** First run seeds all current versions without alerts. After that, only actual new releases trigger the pipeline. Since most repos release infrequently, your first real alert may take days or weeks \u2014 this is normal."
      },
      "typeVersion": 1
    },
    {
      "id": "s4000001",
      "name": "AI Section",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3040,
        416
      ],
      "parameters": {
        "color": 7,
        "width": 708,
        "height": 400,
        "content": "## \ud83e\udde0 AI Analysis\n\nClaude Haiku reads each changelog and returns:\n\u2022 1-2 sentence summary (no marketing fluff)\n\u2022 Breaking change detection + migration steps\n\u2022 CVE and security vulnerability scanning\n\u2022 Urgency rating: **critical** (security/CVE), **recommended** (useful features), or **optional** (minor/cosmetic)\n\nCost: ~$0.01-0.03 per run. Haiku is the cheapest Claude model."
      },
      "typeVersion": 1
    },
    {
      "id": "s5000001",
      "name": "Delivery Section",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4688,
        112
      ],
      "parameters": {
        "color": 7,
        "width": 552,
        "height": 1240,
        "content": "## \ud83d\udcec Delivery\n\nFormats color-coded messages sorted by urgency:\n\u2022 \ud83d\udd34 **Critical** \u2014 security patches, CVEs (red)\n\u2022 \ud83d\udfe0 **Recommended** \u2014 useful features, bugfixes (orange)\n\u2022 \ud83d\udd35 **Optional** \u2014 minor/cosmetic changes (blue)\n\nEach message includes: summary, breaking change warnings, a ready-to-run docker update command, and a link to the full release notes.\n\n**Channels:**\n\u2022 **Discord** \u2014 rich embeds via webhook ([setup](https://www.nxsi.io/guides/discord-webhook))\n\u2022 **Telegram** \u2014 text message via bot API ([setup](https://www.nxsi.io/guides/telegram-bot))\n\u2022 **Slack** \u2014 formatted message via Slack app ([setup](https://www.nxsi.io/guides/slack-bot-token))\n\u2022 **ntfy** \u2014 push notification to your phone via [ntfy.sh](https://ntfy.sh) ([setup](https://www.nxsi.io/guides/ntfy))\n\nAfter delivery, **Update Stored Versions** saves the new version tags so you won't get alerted for the same release again."
      },
      "typeVersion": 1
    },
    {
      "id": "db001001",
      "name": "Prep DB Record",
      "type": "n8n-nodes-base.code",
      "position": [
        3840,
        832
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst prepItems = $('Prep Changelog for AI').all();\nconst results = [];\n\nfunction esc(val) {\n  if (val === null || val === undefined || val === '') return 'NULL';\n  return \"'\" + String(val).replace(/'/g, \"''\") + \"'\";\n}\n\nfor (let i = 0; i < items.length; i++) {\n  const d = items[i].json;\n  const prep = prepItems[i] ? prepItems[i].json : {};\n\n  const repoKey = d.source === 'github'\n    ? d.owner + '/' + d.repo\n    : d.source + ':' + d.owner + '/' + d.repo;\n  const source = d.source || 'github';\n\n  const query = 'INSERT INTO release_history (repo_key, source, tag_name, previous_tag, release_url, changelog_raw, ai_summary, ai_urgency, has_breaking_changes, breaking_details, security_advisory, advisory_severity, update_command) VALUES ('\n    + esc(repoKey) + ', '\n    + esc(source) + ', '\n    + esc(d.tagName) + ', '\n    + esc(d.previousTag || null) + ', '\n    + esc(d.htmlUrl) + ', '\n    + esc(prep.changelog ? prep.changelog.substring(0, 10000) : (d.changelog ? d.changelog.substring(0, 10000) : null)) + ', '\n    + esc(d.summary) + ', '\n    + esc(d.urgency) + ', '\n    + (d.breaking || false) + ', '\n    + esc(d.breakingDetails) + ', '\n    + (d.security || false) + ', '\n    + esc(d.securityDetails ? 'high' : null) + ', '\n    + esc(d.updateCommand || null)\n    + ') ON CONFLICT DO NOTHING';\n\n  results.push({ json: { _dbQuery: query } });\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "db001002",
      "name": "Save to DB",
      "type": "n8n-nodes-base.postgres",
      "onError": "continueRegularOutput",
      "position": [
        4032,
        832
      ],
      "parameters": {
        "query": "={{ $json._dbQuery }}",
        "options": {},
        "operation": "executeQuery"
      },
      "typeVersion": 2.5
    },
    {
      "id": "sw001001",
      "name": "Source Router",
      "type": "n8n-nodes-base.switch",
      "position": [
        1824,
        640
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.source }}",
                    "rightValue": "github"
                  }
                ]
              }
            },
            {
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "notEquals"
                    },
                    "leftValue": "={{ $json.source }}",
                    "rightValue": "github"
                  }
                ]
              }
            }
          ]
        },
        "options": {
          "fallbackOutput": "none"
        }
      },
      "typeVersion": 3.2
    },
    {
      "id": "hr001001",
      "name": "Fetch Registry Tags",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        1968,
        960
      ],
      "parameters": {
        "url": "={{ $json.fetchUrl }}",
        "options": {},
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "n8n-release-watcher/2.0"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "cd001001",
      "name": "Extract Registry Data",
      "type": "n8n-nodes-base.code",
      "position": [
        2368,
        960
      ],
      "parameters": {
        "jsCode": "const watchlist = $('Deduplicate Watchlist').all().filter(function(i) { return i.json.source !== 'github'; });\nconst responses = $input.all();\nconst results = [];\n\nfor (let i = 0; i < watchlist.length; i++) {\n  const reg = watchlist[i].json;\n  const res = responses[i] ? responses[i].json : {};\n\n  let tagName = null;\n  let publishedAt = null;\n\n  if (reg.registry === 'dockerhub') {\n    // Docker Hub returns {results: [{name, last_updated, ...}]}\n    const tags = res.results || [];\n    // Find first non-latest stable tag\n    const stable = tags.find(function(t) {\n      return t.name !== 'latest' && !t.name.includes('-rc') && !t.name.includes('-beta') && !t.name.includes('-alpha');\n    });\n    if (stable) {\n      tagName = stable.name;\n      publishedAt = stable.last_updated || stable.tag_last_pushed;\n    }\n  } else if (reg.registry === 'ghcr') {\n    // GHCR returns {name, tags: ['v1.0', 'latest', ...]}\n    const tags = res.tags || [];\n    const stable = tags.filter(function(t) {\n      return t !== 'latest' && !t.includes('-rc') && !t.includes('-beta');\n    }).sort().reverse();\n    if (stable.length > 0) tagName = stable[0];\n  }\n\n  if (!tagName) continue;\n\n  results.push({\n    json: {\n      owner: reg.namespace,\n      repo: reg.image,\n      label: reg.label,\n      category: reg.category,\n      source: reg.source,\n      registry: reg.registry,\n      namespace: reg.namespace,\n      image: reg.image,\n      tagName: tagName,\n      releaseName: tagName,\n      publishedAt: publishedAt,\n      htmlUrl: reg.registry === 'dockerhub'\n        ? 'https://hub.docker.com/r/' + reg.namespace + '/' + reg.image\n        : 'https://ghcr.io/' + reg.namespace + '/' + reg.image,\n      changelog: 'Container image tag update. Check source repo for release notes.',\n      alertRules: reg.alertRules,\n      dependsOn: reg.dependsOn || [],\n    }\n  });\n}\n\nif (results.length === 0) {\n  return [{ json: { _noRegistryReleases: true } }];\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "mg001001",
      "name": "Merge All Sources",
      "type": "n8n-nodes-base.merge",
      "position": [
        2528,
        640
      ],
      "parameters": {},
      "typeVersion": 3.1
    },
    {
      "id": "s6000001",
      "name": "Registry Section",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1952,
        816
      ],
      "parameters": {
        "color": 7,
        "width": 540,
        "height": 292,
        "content": "## \ud83d\udce6 Multi-Registry Support\n\nFetches latest stable tags from Docker Hub and GHCR container registries. Automatically filters out `latest`, `-rc`, `-beta`, and `-alpha` tags. Results merge with GitHub releases before version comparison."
      },
      "typeVersion": 1
    },
    {
      "id": "ar001001",
      "name": "Apply Alert Rules",
      "type": "n8n-nodes-base.code",
      "position": [
        3840,
        672
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n  const d = item.json;\n  const rules = d.alertRules || { channels: ['discord', 'telegram'], instantAlert: false, urgencyOverride: null };\n\n  const finalUrgency = rules.urgencyOverride || d.urgency || 'optional';\n  const isInstant = rules.instantAlert === true || finalUrgency === 'critical';\n  const channels = rules.channels || ['discord', 'telegram'];\n\n  results.push({\n    json: {\n      ...d,\n      finalUrgency: finalUrgency,\n      isInstant: isInstant,\n      channels: channels,\n    }\n  });\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "ur001001",
      "name": "Urgency Router",
      "type": "n8n-nodes-base.switch",
      "position": [
        4048,
        672
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "422460dc-8374-485a-a071-12cf0934b0f5",
                    "operator": {
                      "type": "boolean",
                      "operation": "true"
                    },
                    "leftValue": "={{ $json.isInstant }}",
                    "rightValue": true
                  }
                ]
              }
            },
            {
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "33e6e5a2-3551-42c7-83f9-242d778dfebe",
                    "operator": {
                      "type": "boolean",
                      "operation": "false"
                    },
                    "leftValue": "={{ $json.isInstant }}",
                    "rightValue": false
                  }
                ]
              }
            }
          ]
        },
        "options": {
          "fallbackOutput": "none"
        }
      },
      "typeVersion": 3.2
    },
    {
      "id": "fi001001",
      "name": "Format Instant Alert",
      "type": "n8n-nodes-base.code",
      "position": [
        4272,
        624
      ],
      "parameters": {
        "jsCode": "// Format individual instant alerts (one per critical/instant-flagged release)\nconst _cfg = $('Configure Watcher').first().json;\nconst items = $input.all();\nconst results = [];\n\nconst emoji = { critical: '\\ud83d\\udd34', recommended: '\\ud83d\\udfe0', optional: '\\ud83d\\udd35' };\n\n// Map of update commands\nconst dockerUpdateMap = {\n  'immich-app/immich': 'docker compose pull && docker compose up -d',\n  'dani-garcia/vaultwarden': 'docker pull vaultwarden/server:TAG && docker compose up -d',\n  'traefik/traefik': 'docker pull traefik:TAG && docker compose up -d',\n  'n8n-io/n8n': 'docker pull n8nio/n8n:TAG && docker compose up -d',\n};\n\nfunction getUpdateCommand(d) {\n  if (d.source === 'dockerhub') return 'docker pull ' + d.namespace + '/' + d.image + ':' + d.tagName;\n  if (d.source === 'ghcr') return 'docker pull ghcr.io/' + d.namespace + '/' + d.image + ':' + d.tagName;\n  var key = d.owner + '/' + d.repo;\n  var cmd = dockerUpdateMap[key];\n  if (cmd) return cmd.replace(/TAG/g, d.tagName);\n  return '# Check ' + key + ' release notes';\n}\n\nfor (const item of items) {\n  const d = item.json;\n  const e = emoji[d.finalUrgency] || '\\ud83d\\udd34';\n  const sourceTag = d.source !== 'github' ? ' [' + d.source.toUpperCase() + ']' : '';\n  const updateCmd = getUpdateCommand(d);\n\n  let text = e + ' URGENT: ' + d.label + ' ' + d.tagName + sourceTag + '\\n';\n  text += d.summary + '\\n';\n  if (d.breaking) text += '\\u26a0\\ufe0f BREAKING: ' + (d.breakingDetails || 'See release notes') + '\\n';\n  if (d.security) text += '\\ud83d\\udee1\\ufe0f SECURITY: ' + (d.securityDetails || 'Security-related') + '\\n';\n  if (d.keyChanges && d.keyChanges.length > 0) text += 'Key changes: ' + d.keyChanges.join('; ') + '\\n';\n  text += '\\ud83d\\udcbb `' + updateCmd + '`\\n';\n  text += '\\u2192 ' + d.htmlUrl;\n\n  const color = d.finalUrgency === 'critical' ? 16711680 : 16750848;\n  results.push({\n    json: {\n      channels: d.channels,\n      digest: text,\n      telegramMessage: text,\n      discordPayload: {\n        content: e + ' **URGENT UPDATE** \\u2014 ' + d.label,\n        embeds: [{\n          title: d.label + ' ' + d.tagName + sourceTag,\n          description: d.summary + (d.security ? '\\n\\n\\ud83d\\udee1\\ufe0f ' + (d.securityDetails || 'Security update') : '') + '\\n\\n```\\n' + updateCmd + '\\n```',\n          url: d.htmlUrl,\n          color: color,\n          footer: { text: d.finalUrgency.toUpperCase() + (d.security ? ' | SECURITY' : '') }\n        }]\n      },\n      slackText: e + ' *URGENT: ' + d.label + ' ' + d.tagName + '*\\n' + d.summary + (d.security ? '\\n\\ud83d\\udee1\\ufe0f ' + (d.securityDetails || 'Security') : '') + '\\n```' + updateCmd + '```\\n<' + d.htmlUrl + '|Release Notes>',\n      ntfyPayload: { topic: (_cfg.ntfy_topic || 'release-watcher'), title: 'URGENT: ' + d.label + ' ' + d.tagName, message: d.summary + (d.security ? ' [SECURITY]' : ''), priority: d.finalUrgency === 'critical' ? 5 : 4, tags: d.security ? ['warning', 'shield'] : ['warning'] },\n      _storageKey: d._storageKey,\n      tagName: d.tagName,\n      updates: [{ key: d._storageKey || (d.owner + '/' + d.repo), tag: d.tagName }],\n    }\n  });\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "cr001001",
      "name": "Channel Router",
      "type": "n8n-nodes-base.switch",
      "position": [
        4496,
        688
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "b3627aa2-24a9-4a3f-bbed-350cd836b7f5",
                    "operator": {
                      "type": "string",
                      "operation": "exists"
                    },
                    "leftValue": "={{ $json.discordPayload.content }}",
                    "rightValue": ""
                  }
                ]
              }
            },
            {
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "5d40425a-690d-496b-9c15-77e4ee21d06e",
                    "operator": {
                      "type": "string",
                      "operation": "exists"
                    },
                    "leftValue": "={{ $json.telegramMessage }}",
                    "rightValue": ""
                  }
                ]
              }
            },
            {
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "bf4db3ec-c79b-4f56-b64e-e98ca3347dd8",
                    "operator": {
                      "type": "string",
                      "operation": "exists"
                    },
                    "leftValue": "={{ $json.updates[0].key }}",
                    "rightValue": ""
                  }
                ]
              }
            }
          ]
        },
        "options": {
          "allMatchingOutputs": true
        },
        "looseTypeValidation": true
      },
      "typeVersion": 3.2
    },
    {
      "id": "sl001001",
      "name": "Send Slack",
      "type": "n8n-nodes-base.slack",
      "onError": "continueRegularOutput",
      "position": [
        4800,
        896
      ],
      "parameters": {
        "text": "={{ $json.slackText || $json.digest }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Configure Watcher').first().json.slack_channel_id }}"
        },
        "otherOptions": {}
      },
      "typeVersion": 2.2
    },
    {
      "id": "nt001001",
      "name": "Send ntfy",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        4800,
        1072
      ],
      "parameters": {
        "url": "=https://ntfy.sh/{{ $json.ntfyPayload ? $json.ntfyPayload.topic : 'release-watcher' }}",
        "body": "={{ $json.ntfyPayload ? $json.ntfyPayload.message : $json.digest }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "raw",
        "sendHeaders": true,
        "rawContentType": "text/plain",
        "headerParameters": {
          "parameters": [
            {
              "name": "Title",
              "value": "={{ $json.ntfyPayload ? $json.ntfyPayload.title : 'Stack Update Digest' }}"
            },
            {
              "name": "Priority",
              "value": "={{ $json.ntfyPayload ? String($json.ntfyPayload.priority) : '3' }}"
            },
            {
              "name": "Tags",
              "value": "={{ $json.ntfyPayload && $json.ntfyPayload.tags ? $json.ntfyPayload.tags.join(',') : 'package' }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "s7000001",
      "name": "Routing Section",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3776,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 844,
        "height": 736,
        "content": "## \ud83d\udea6 Alert Routing\n\nHere's what happens after AI analysis:\n\n**1. Apply Alert Rules**\nChecks each update against your per-repo settings from Build Repo Watchlist. Adds which channels to notify, any urgency overrides, and whether to send instantly or batch.\n\n**2. Urgency Router** \u2014 splits into two paths:\n\u2022 \u26a1 **Instant** \u2192 critical/security updates + repos with `instantAlert: true` \u2192 sent immediately\n\u2022 \ud83d\udccb **Batch** \u2192 everything else \u2192 combined into one daily digest message\n\n**3. Save to DB**\nEvery release is saved to the PostgreSQL `release_history` table for long-term tracking, regardless of which path it takes.\n\n**4. Channel Router** \u2192 sends to your enabled channels, then updates stored versions so you won't be alerted again."
      },
      "typeVersion": 1
    },
    {
      "id": "cfg001001",
      "name": "Configure Watcher",
      "type": "n8n-nodes-base.set",
      "position": [
        272,
        768
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "cfg-01",
              "name": "github_token",
              "type": "string",
              "value": ""
            },
            {
              "id": "cfg-02",
              "name": "enable_auto_detect",
              "type": "boolean",
              "value": true
            },
            {
              "id": "cfg-03",
              "name": "compose_url",
              "type": "string",
              "value": ""
            },
            {
              "id": "cfg-04",
              "name": "compose_content",
              "type": "string",
              "value": ""
            },
            {
              "id": "cfg-05",
              "name": "enable_discord",
              "type": "boolean",
              "value": true
            },
            {
              "id": "cfg-06",
              "name": "discord_webhook_url",
              "type": "string",
              "value": "YOUR_WEBHOOK_URL"
            },
            {
              "id": "cfg-07",
              "name": "enable_telegram",
              "type": "boolean",
              "value": false
            },
            {
              "id": "cfg-08",
              "name": "telegram_chat_id",
              "type": "string",
              "value": "YOUR_CHAT_ID"
            },
            {
              "id": "cfg-09",
              "name": "enable_slack",
              "type": "boolean",
              "value": false
            },
            {
              "id": "cfg-10",
              "name": "slack_channel_id",
              "type": "string",
              "value": "YOUR_CHANNEL_ID"
            },
            {
              "id": "cfg-11",
              "name": "enable_ntfy",
              "type": "boolean",
              "value": false
            },
            {
              "id": "cfg-12",
              "name": "ntfy_topic",
              "type": "string",
              "value": "release-watcher"
            },
            {
              "id": "cfg-13",
              "name": "default_channels",
              "type": "string",
              "value": "discord"
            },
            {
              "id": "cfg-14",
              "name": "default_instant_alert",
              "type": "boolean",
              "value": false
            },
            {
              "id": "cfg-15",
              "name": "test_mode",
              "type": "boolean",
              "value": true
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "ad001001",
      "name": "Auto-Detect Enabled?",
      "type": "n8n-nodes-base.if",
      "position": [
        480,
        768
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "ad-check",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.enable_auto_detect }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "ad001002",
      "name": "Skip Auto-Detect",
      "type": "n8n-nodes-base.set",
      "position": [
        720,
        656
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "skip-01",
              "name": "_noAutoDetected",
              "type": "boolean",
              "value": true
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "ad001003",
      "name": "Has Compose URL?",
      "type": "n8n-nodes-base.if",
      "position": [
        720,
        816
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "url-check",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.compose_url }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "ad001004",
      "name": "Fetch Compose File",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        944,
        720
      ],
      "parameters": {
        "url": "={{ $json.compose_url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "n8n-release-watcher/2.0"
            },
            {
              "name": "Accept",
              "value": "text/plain, application/x-yaml, */*"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "ad001005",
      "name": "Parse and Map Compose",
      "type": "n8n-nodes-base.code",
      "position": [
        1168,
        816
      ],
      "parameters": {
        "jsCode": "// Parse docker-compose.yml to auto-detect services\nvar config = $('Configure Watcher').first().json;\n\n// Get compose content from HTTP fetch or pasted content\nvar composeText = '';\ntry {\n  var fetched = $input.all();\n  if (fetched.length > 0) {\n    var f = fetched[0].json;\n    if (typeof f === 'string') composeText = f;\n    else if (f.data) composeText = String(f.data);\n    else if (f.body) composeText = String(f.body);\n    else if (f.response) composeText = String(f.response);\n  }\n} catch (e) {}\n\nif (!composeText && config.compose_content) {\n  composeText = config.compose_content;\n}\n\nif (!composeText) {\n  return [{ json: { _noAutoDetected: true } }];\n}\n\n// Extract image references via regex\nvar imageRegex = /image:\\s*['\"]?([^'\":\\s#]+)(?::([^'\":\\s#]+))?['\"]?/g;\nvar images = [];\nvar match;\nwhile ((match = imageRegex.exec(composeText)) !== null) {\n  images.push({ raw: match[1], tag: match[2] || 'latest' });\n}\n\nif (images.length === 0) {\n  return [{ json: { _noAutoDetected: true } }];\n}\n\n// Lookup table: Docker image name -> GitHub repo / registry info\nvar imageToRepo = {\n  'vaultwarden/server': { owner: 'dani-garcia', repo: 'vaultwarden', label: 'Vaultwarden', category: 'security', source: 'github' },\n  'traefik': { owner: 'traefik', repo: 'traefik', label: 'Traefik', category: 'networking', source: 'github' },\n  'n8nio/n8n': { owner: 'n8n-io', repo: 'n8n', label: 'n8n', category: 'automation', source: 'github' },\n  'jellyfin/jellyfin': { owner: 'jellyfin', repo: 'jellyfin', label: 'Jellyfin', category: 'media', source: 'github' },\n  'ghcr.io/immich-app/immich-server': { owner: 'immich-app', repo: 'immich', label: 'Immich', category: 'media', source: 'github' },\n  'immich-app/immich-server': { owner: 'immich-app', repo: 'immich', label: 'Immich', category: 'media', source: 'github' },\n  'grafana/grafana': { owner: 'grafana', repo: 'grafana', label: 'Grafana', category: 'monitoring', source: 'github' },\n  'prom/prometheus': { owner: 'prometheus', repo: 'prometheus', label: 'Prometheus', category: 'monitoring', source: 'github' },\n  'louislam/uptime-kuma': { owner: 'louislam', repo: 'uptime-kuma', label: 'Uptime Kuma', category: 'monitoring', source: 'github' },\n  'containrrr/watchtower': { owner: 'containrrr', repo: 'watchtower', label: 'Watchtower', category: 'management', source: 'github' },\n  'nextcloud': { owner: 'nextcloud', repo: 'server', label: 'Nextcloud', category: 'productivity', source: 'github' },\n  'homeassistant/home-assistant': { owner: 'home-assistant', repo: 'core', label: 'Home Assistant', category: 'automation', source: 'github' },\n  'ghcr.io/home-assistant/home-assistant': { owner: 'home-assistant', repo: 'core', label: 'Home Assistant', category: 'automation', source: 'github' },\n  'portainer/portainer-ce': { owner: 'portainer', repo: 'portainer', label: 'Portainer', category: 'management', source: 'github' },\n  'gitea/gitea': { owner: 'go-gitea', repo: 'gitea', label: 'Gitea', category: 'development', source: 'github' },\n  'pihole/pihole': { owner: 'pi-hole', repo: 'pi-hole', label: 'Pi-hole', category: 'networking', source: 'github' },\n  'postgres': { owner: 'postgres', repo: 'postgres', label: 'PostgreSQL', category: 'database', source: 'dockerhub', registry: 'dockerhub', namespace: 'library', image: 'postgres' },\n  'redis': { owner: 'redis', repo: 'redis', label: 'Redis', category: 'database', source: 'dockerhub', registry: 'dockerhub', namespace: 'library', image: 'redis' },\n  'caddy': { owner: 'caddyserver', repo: 'caddy', label: 'Caddy', category: 'networking', source: 'github' },\n  'nginx': { owner: 'nginx', repo: 'nginx', label: 'Nginx', category: 'networking', source: 'dockerhub', registry: 'dockerhub', namespace: 'library', image: 'nginx' },\n  'blakeblackshear/frigate': { owner: 'blakeblackshear', repo: 'frigate', label: 'Frigate', category: 'media', source: 'github' },\n  'ghcr.io/blakeblackshear/frigate': { owner: 'blakeblackshear', repo: 'frigate', label: 'Frigate', category: 'media', source: 'github' },\n  'linuxserver/wireguard': { owner: 'linuxserver', repo: 'docker-wireguard', label: 'WireGuard', category: 'networking', source: 'github' },\n  'linuxserver/heimdall': { owner: 'linuxserver', repo: 'Heimdall', label: 'Heimdall', category: 'management', source: 'github' },\n  'adguard/adguardhome': { owner: 'AdguardTeam', repo: 'AdGuardHome', label: 'AdGuard Home', category: 'networking', source: 'github' },\n  'codercom/code-server': { owner: 'coder', repo: 'code-server', label: 'code-server', category: 'development', source: 'github' },\n  'authelia/authelia': { owner: 'authelia', repo: 'authelia', label: 'Authelia', category: 'security', source: 'github' },\n};\n\nvar defaultChannels = config.default_channels ? config.default_channels.split(',').map(function(c) { return c.trim(); }) : ['discord'];\nvar defaultAlertRules = {\n  urgencyOverride: null,\n  channels: defaultChannels,\n  instantAlert: config.default_instant_alert === true\n};\n\nvar results = [];\nvar seen = {};\n\nfor (var i = 0; i < images.length; i++) {\n  var img = images[i];\n  var lookupKey = img.raw;\n  var stripped = lookupKey.replace(/^ghcr\\.io\\//, '').replace(/^docker\\.io\\//, '').replace(/^library\\//, '');\n  var mapped = imageToRepo[lookupKey] || imageToRepo[stripped];\n\n  if (mapped) {\n    var uniqueKey = mapped.source + ':' + mapped.owner + '/' + mapped.repo;\n    if (seen[uniqueKey]) continue;\n    seen[uniqueKey] = true;\n    var item = {\n      owner: mapped.owner, repo: mapped.repo, label: mapped.label,\n      category: mapped.category, source: mapped.source,\n      dependsOn: [], _autoDetected: true,\n      alertRules: defaultAlertRules,\n    };\n    if (mapped.source === 'github') {\n      item._githubToken = config.github_token || '';\n    } else {\n      item.registry = mapped.registry || 'dockerhub';\n      item.namespace = mapped.namespace || mapped.owner;\n      item.image = mapped.image || mapped.repo;\n      item.repoKey = item.registry + ':' + item.namespace + '/' + item.image;\n      if (item.registry === 'dockerhub') {\n        item.fetchUrl = 'https://hub.docker.com/v2/repositories/' + item.namespace + '/' + item.image + '/tags?page_size=5&ordering=last_updated';\n      }\n      item.fetchHeaders = {};\n    }\n    results.push({ json: item });\n  } else {\n    // Unknown image \u2014 watch via Docker Hub tag API\n    var parts = stripped.split('/');\n    var ns = parts.length > 1 ? parts[0] : 'library';\n    var im = parts.length > 1 ? parts[1] : parts[0];\n    var uKey = 'dockerhub:' + ns + '/' + im;\n    if (seen[uKey]) continue;\n    seen[uKey] = true;\n    results.push({\n      json: {\n        owner: ns, repo: im,\n        label: im.charAt(0).toUpperCase() + im.slice(1) + ' (auto-detected)',\n        category: 'other', source: 'dockerhub', registry: 'dockerhub',\n        namespace: ns, image: im, repoKey: uKey,\n        fetchUrl: 'https://hub.docker.com/v2/repositories/' + ns + '/' + im + '/tags?page_size=5&ordering=last_updated',\n        fetchHeaders: {}, dependsOn: [], _autoDetected: true,\n        alertRules: defaultAlertRules,\n      }\n    });\n  }\n}\n\nif (results.length === 0) {\n  return [{ json: { _noAutoDetected: true } }];\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "ad001006",
      "name": "Merge Manual + Auto",
      "type": "n8n-nodes-base.merge",
      "position": [
        1376,
        640
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "ad001007",
      "name": "Deduplicate Watchlist",
      "type": "n8n-nodes-base.code",
      "position": [
        1584,
        640
      ],
      "parameters": {
        "jsCode": "// Deduplicate manual + auto-detected watchlist items\n// Manual entries (no _autoDetected flag) take priority over auto-detected dupes\nvar items = $input.all();\nvar unique = {};\nvar results = [];\n\nfor (var i = 0; i < items.length; i++) {\n  var d = items[i].json;\n  if (d._noAutoDetected) continue;\n\n  var key;\n  if (d.source === 'github') {\n    key = 'github:' + d.owner + '/' + d.repo;\n  } else {\n    key = (d.registry || d.source) + ':' + (d.namespace || d.owner) + '/' + (d.image || d.repo);\n  }\n\n  if (unique[key]) {\n    if (!d._autoDetected && unique[key]._autoDetected) {\n      unique[key] = d;\n    }\n    continue;\n  }\n  unique[key] = d;\n}\n\nfor (var k in unique) {\n  results.push({ json: unique[k] });\n}\n\nif (results.length === 0) {\n  return [{ json: { _empty: true, source: 'github' } }];\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "s9000001",
      "name": "Auto-Detect Section",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 1108,
        "height": 704,
        "content": "## \ud83d\udc33 Docker Compose Auto-Detection\n\n**What this does:** Reads your docker-compose.yml and automatically adds every service to the watchlist \u2014 no manual entry needed.\n\n**How to use it:**\n1. In **Configure Watcher**, set `enable_auto_detect` to `true`\n2. Provide your compose file in ONE of two ways:\n   \u2022 **compose_url** \u2014 a direct URL to the raw YAML file (GitHub raw link, Gitea, or any URL that returns the file contents)\n   \u2022 **compose_content** \u2014 paste your entire docker-compose.yml text directly into the field\n\n**What gets recognized:** 25+ common homelab images including Traefik, Immich, Vaultwarden, Grafana, Jellyfin, Home Assistant, Pi-hole, and more are mapped to their GitHub repos for full release notes. Unknown images fall back to Docker Hub tag monitoring.\n\nManual repos in **Build Repo Watchlist** always override auto-detected duplicates."
      },
      "typeVersion": 1
    },
    {
      "id": "e284c6ea-5915-421e-9f62-1d75a5b57dd9",
      "name": "Warning \u2014 Anthropic API Cost",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3200,
        832
      ],
      "parameters": {
        "color": 3,
        "width": 464,
        "height": 552,
        "content": "\n\n\n\n\n\n\n\n\n\n\n\n## \ud83d\udd11 Anthropic API Key Required\n\nThis workflow uses **Claude Haiku** to analyze changelogs. You need an Anthropic API key to use it.\n\n### Setup:\n1. Go to [console.anthropic.com](https://console.anthropic.com) \u2192 **API Keys** \u2192 **Create Key**\n2. In n8n: **Credentials** \u2192 **Add Credential** \u2192 search **Anthropic**\n3. Paste your API key and save\n4. Double-click the **Claude Haiku** node \u2192 select your new credential\n\n\ud83d\udcd6 [Full Anthropic API setup guide \u2192](https://www.nxsi.io/guides/anthropic-api-key)\n\ud83d\udcd6 [Claude API setup for n8n \u2192](https://www.nxsi.io/guides/claude-api-setup)\n\n\ud83d\udcb0 **Cost:** ~$0.25 per million input tokens. A typical run monitoring 10 repos costs about $0.01-0.03."
      },
      "typeVersion": 1
    }
  ],
  "settings": {
    "timezone": "America/Chicago",
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false,
    "executionOrder": "v1",
    "saveExecutionProgress": true,
    "saveDataSuccessExecution": "all"
  },
  "connections": {
    "Claude Haiku": {
      "ai_languageModel": [
        [
          {
            "node": "Analyze Release",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Has Updates?": {
      "main": [
        [
          {
            "node": "Prep Changelog for AI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Digest": {
      "main": [
        [
          {
            "node": "Channel Router",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Source Router": {
      "main": [
        [
          {
            "node": "Fetch Latest Release",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Registry Tags",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Channel Router": {
      "main": [
        [
          {
            "node": "Send Discord",
            "type": "main",
            "index": 0
          },
          {
            "node": "Send Slack",
            "type": "main",
            "index": 0
          },
          {
            "node": "Send ntfy",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send Telegram",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Update Stored Versions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep DB Record": {
      "main": [
        [
          {
            "node": "Save to DB",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Urgency Router": {
      "main": [
        [
          {
            "node": "Format Instant Alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyze Release": {
      "main": [
        [
          {
            "node": "Parse AI Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare Versions": {
      "main": [
        [
          {
            "node": "Has Updates?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Compose URL?": {
      "main": [
        [
          {
            "node": "Fetch Compose File",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Parse and Map Compose",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Configure Watcher",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip Auto-Detect": {
      "main": [
        [
          {
            "node": "Merge Manual + Auto",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Apply Alert Rules": {
      "main": [
        [
          {
            "node": "Urgency Router",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configure Watcher": {
      "main": [
        [
          {
            "node": "Build Repo Watchlist",
            "type": "main",
            "index": 0
          },
          {
            "node": "Auto-Detect Enabled?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge All Sources": {
      "main": [
        [
          {
            "node": "Compare Versions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse AI Response": {
      "main": [
        [
          {
            "node": "Prep DB Record",
            "type": "main",
            "index": 0
          },
          {
            "node": "Apply Alert Rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Compose File": {
      "main": [
        [
          {
            "node": "Parse and Map Compose",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Registry Tags": {
      "main": [
        [
          {
            "node": "Extract Registry Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Manual + Auto": {
      "main": [
        [
          {
            "node": "Deduplicate Watchlist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Auto-Detect Enabled?": {
      "main": [
        [
          {
            "node": "Has Compose URL?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip Auto-Detect",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Repo Watchlist": {
      "main": [
        [
          {
            "node": "Merge Manual + Auto",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Release Data": {
      "main": [
        [
          {
            "node": "Merge All Sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Latest Release": {
      "main": [
        [
          {
            "node": "Extract Release Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Instant Alert": {
      "main": [
        [
          {
            "node": "Channel Router",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Deduplicate Watchlist": {
      "main": [
        [
          {
            "node": "Source Router",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Registry Data": {
      "main": [
        [
          {
            "node": "Merge All Sources",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Parse and Map Compose": {
      "main": [
        [
          {
            "node": "Merge Manual + Auto",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Prep Changelog for AI": {
      "main": [
        [
          {
            "node": "Analyze Release",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}