diff --git a/apps/automation/scripts/deploy-workflows.mjs b/apps/automation/scripts/deploy-workflows.mjs index a87ee1e..c5d6289 100644 --- a/apps/automation/scripts/deploy-workflows.mjs +++ b/apps/automation/scripts/deploy-workflows.mjs @@ -139,6 +139,33 @@ async function listWorkflows() { return all; } +// Resolve tag names to ids on the target instance, creating any that are missing. +async function resolveTags(names) { + const all = []; + let cursor; + do { + const url = new URL(`${N8N_URL}/api/v1/tags`); + url.searchParams.set('limit', '100'); + if (cursor) url.searchParams.set('cursor', cursor); + const r = await fetch(url, { headers }); + if (!r.ok) throw new Error(`list tags failed: ${r.status} ${await r.text()}`); + const b = await r.json(); + all.push(...(b.data ?? [])); + cursor = b.nextCursor; + } while (cursor); + const byName = new Map(all.map((t) => [t.name, t.id])); + const out = new Map(); + for (const n of names) { + if (byName.has(n)) out.set(n, byName.get(n)); + else { + const res = await api('POST', '/api/v1/tags', { name: n }); + out.set(n, res.id); + console.log(` created tag ${n}`); + } + } + return out; +} + function loadLocal() { const out = []; for (const entry of readdirSync(WORKFLOWS_DIR, { withFileTypes: true })) { @@ -175,18 +202,32 @@ async function main() { for (const n of wf.nodes ?? []) if (byName.has(n.name)) n.credentials = byName.get(n.name); } - // Pass 1: create/update by name (preserving existing credential bindings). + // Create/update each workflow, re-linking by-id references to the target's ids + // BEFORE the PUT. n8n refuses to publish an active workflow whose executeWorkflow + // target isn't published, so the reference must already be the target's id at write + // time. Push referenced sub-workflows first (error-handler, render-email) so their + // ids are known; seed `ids` from existing so re-deploys resolve every ref up front. const existing = new Map((await listWorkflows()).map((w) => [w.name, w.id])); - const ids = new Map(); + const ids = new Map(existing); + const byName = new Map(local.map((w) => [w.name, w])); let created = 0, updated = 0, failed = 0; + + const order = []; + for (const nm of [ERROR_HANDLER_NAME, RENDER_EMAIL_NAME]) { + if (byName.has(nm)) order.push(byName.get(nm)); + } for (const wf of local) { + if (wf.name !== ERROR_HANDLER_NAME && wf.name !== RENDER_EMAIL_NAME) order.push(wf); + } + + for (const wf of order) { try { + relink(wf, ids.get(RENDER_EMAIL_NAME), ids.get(ERROR_HANDLER_NAME)); if (existing.has(wf.name)) { - const id = existing.get(wf.name); + const id = ids.get(wf.name); const current = await api('GET', `/api/v1/workflows/${id}`); preserveCreds(wf, current); await api('PUT', `/api/v1/workflows/${id}`, sanitize(wf)); - ids.set(wf.name, id); updated++; console.log(` updated ${wf.name}`); } else { const res = await api('POST', '/api/v1/workflows', sanitize(wf)); @@ -198,23 +239,28 @@ async function main() { } } - // Pass 2: re-link by-id references to the target instance's ids. - const renderId = ids.get(RENDER_EMAIL_NAME); - const errorId = ids.get(ERROR_HANDLER_NAME); - let relinked = 0; - for (const wf of local) { - if (!ids.has(wf.name)) continue; - if (relink(wf, renderId, errorId)) { + // Pass 3: tag every deployed workflow with community-hub + the environment tag. + const envTag = NAMESPACE === 'strapi' ? 'production' : NAMESPACE; + let tagged = 0; + try { + const tagIds = await resolveTags(['community-hub', envTag]); + const body = [{ id: tagIds.get('community-hub') }, { id: tagIds.get(envTag) }]; + for (const wf of order) { // only the workflows we deployed, never pre-existing ones + const id = ids.get(wf.name); + if (!id) continue; try { - await api('PUT', `/api/v1/workflows/${ids.get(wf.name)}`, sanitize(wf)); - relinked++; + await api('PUT', `/api/v1/workflows/${id}/tags`, body); + tagged++; } catch (e) { - console.error(` relink failed ${wf.name}: ${e.message}`); + console.error(` tag failed ${wf.name}: ${e.message}`); } } + console.log(` tagged ${tagged} workflows: community-hub + ${envTag}`); + } catch (e) { + console.error(` tagging skipped: ${e.message}`); } - console.log(`\nSummary: ${created} created, ${updated} updated, ${relinked} re-linked, ${failed} failed.`); + console.log(`\nSummary: ${created} created, ${updated} updated, ${tagged} tagged, ${failed} failed.`); console.log('Imported INACTIVE. Next: bind credentials in the n8n UI (see README), then activate.'); if (failed > 0) process.exit(1); } diff --git a/apps/automation/workflows/README.md b/apps/automation/workflows/README.md index 9929946..4c57af2 100644 --- a/apps/automation/workflows/README.md +++ b/apps/automation/workflows/README.md @@ -90,8 +90,10 @@ pnpm --filter automation run workflows:deploy It (1) rewrites every HTTP node's base URL from the committed `http://localhost:1337` placeholder to `STRAPI_BASE_URL`, (2) rewrites webhook paths to -`/`, and (3) re-points `executeWorkflow → render-email` and -`Settings → Error Workflow → error-handler` to the target instance's ids. +`/`, (3) re-points `executeWorkflow → render-email` and +`Settings → Error Workflow → error-handler` to the target instance's ids, and +(4) tags every workflow `community-hub` + the environment tag (`staging`, or +`production` when the namespace is `strapi`), creating the tags if missing. > **Plain `workflows:import`** is the no-substitution variant (local dev): matches by > name, updates in place, but leaves the `localhost` base URL and `strapi/` paths and diff --git a/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json b/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json index 6fc40d6..37e2144 100644 --- a/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json +++ b/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json @@ -92,7 +92,7 @@ ], "parameters": { "language": "javaScript", - "jsCode": "const item = $input.first().json;\n\nconst shell = `\n\n\n
\n Strapi Community\n
\n
${item.body}
\n
\n You're receiving this because you interacted with the Strapi community hub.\n
\n\n`;\n\nreturn [{ json: { ...item, html_body: shell } }];" + "jsCode": "const item = $input.first().json;\n\n// Email body is Strapi richtext (markdown). Convert to HTML before wrapping.\n// HTML-escape first (the body contains interpolated reviewer feedback / decline reasons).\nfunction escapeHtml(s) {\n return String(s == null ? '' : s)\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\nfunction inline(s) {\n // s is already HTML-escaped; '>' is now '>'\n return s\n .replace(/`([^`]+)`/g, '$1')\n .replace(/\\*\\*([^*]+)\\*\\*/g, '$1')\n .replace(/(^|[^*])\\*([^*\\n]+)\\*(?!\\*)/g, '$1$2')\n .replace(/(https?:\\/\\/[^\\s<]+)/g, '$1');\n}\n\nfunction markdownToHtml(md) {\n const esc = escapeHtml(String(md || '').trim());\n const blocks = esc.split(/\\n\\s*\\n/);\n return blocks.map((block) => {\n const lines = block.split('\\n');\n if (lines.length && lines.every((l) => /^\\s*>\\s?/.test(l))) {\n const inner = lines.map((l) => l.replace(/^\\s*>\\s?/, '')).join('
');\n return `
${inline(inner)}
`;\n }\n const h = block.match(/^(#{1,3})\\s+([\\s\\S]+)$/);\n if (h && lines.length === 1) {\n const lvl = Math.min(h[1].length + 1, 4);\n return `${inline(h[2])}`;\n }\n return `

${inline(lines.join('
'))}

`;\n }).join('\\n');\n}\n\nconst bodyHtml = markdownToHtml(item.body);\n\nconst shell = `\n\n\n
\n Strapi Community\n
\n
${bodyHtml}
\n \n\n`;\n\nreturn [{ json: { ...item, html_body: shell } }];" } }, { diff --git a/apps/automation/workflows/security-scan/workflow.json b/apps/automation/workflows/security-scan/workflow.json index 2530ac1..e33dba3 100644 --- a/apps/automation/workflows/security-scan/workflow.json +++ b/apps/automation/workflows/security-scan/workflow.json @@ -356,7 +356,7 @@ ], "parameters": { "language": "javaScript", - "jsCode": "try {\nconst merged = $input.first().json;\n\nfunction pick(stageName) {\n const stages = Array.isArray(merged.stage) ? merged.stage : [merged.stage];\n const results = Array.isArray(merged.result) ? merged.result : [merged.result];\n for (let i = 0; i < stages.length; i++) {\n if (stages[i] === stageName) return results[i];\n }\n return null;\n}\n\nconst depsR = pick('dependencies');\nconst aiR = pick('ai_analysis');\n\nfunction stagePassed(r) {\n if (!r) return null;\n if (r.skipped) return null;\n if (typeof r.passed === 'boolean') return r.passed;\n return null;\n}\n\nconst summary = {\n runAt: new Date().toISOString(),\n dependencies_passed: stagePassed(depsR),\n ai_risk_level: aiR?.parsed?.risk_level ?? null,\n ai_recommendation: aiR?.parsed?.recommendation ?? null,\n ai_files_scanned: aiR?.files_scanned ?? null,\n ai_files_available: aiR?.files_available ?? null,\n ai_scan_source: aiR?.scan_source ?? null,\n ai_bytes_scanned: aiR?.bytes_scanned ?? null,\n vulnerable_dep_count: depsR?.vulnerable_count ?? null,\n ai_concern_count: Array.isArray(aiR?.parsed?.concerns) ? aiR.parsed.concerns.length : null,\n registry: depsR?.registry ?? null,\n registry_available: depsR?.registry_available ?? null,\n not_implemented: depsR?.not_implemented ?? null,\n package_name: depsR?.package_name ?? null,\n version: depsR?.version ?? null,\n has_install_scripts: depsR?.has_install_scripts ?? null,\n install_scripts: depsR?.install_scripts ?? null,\n cross_check_mismatch_count: Array.isArray(depsR?.cross_check_mismatches) ? depsR.cross_check_mismatches.length : null,\n};\n\nreturn [{ json: { stage: 'summary', result: summary, status: 'completed' } }];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: summary-1\n return [{ json: { stage: \"summary\", result: { runAt: new Date().toISOString(), error: errMsg, passed: false }, status: \"failed\" } }];\n}" + "jsCode": "try {\nconst depsR = $('Format Package Scan Result').first().json.result ?? null;\nconst aiR = $('Format AI Result').first().json.result ?? null;\n\nfunction stagePassed(r) {\n if (!r) return null;\n if (r.skipped) return null;\n if (typeof r.passed === 'boolean') return r.passed;\n return null;\n}\n\nconst summary = {\n runAt: new Date().toISOString(),\n dependencies_passed: stagePassed(depsR),\n ai_risk_level: aiR?.parsed?.risk_level ?? null,\n ai_recommendation: aiR?.parsed?.recommendation ?? null,\n ai_files_scanned: aiR?.files_scanned ?? null,\n ai_files_available: aiR?.files_available ?? null,\n ai_scan_source: aiR?.scan_source ?? null,\n ai_bytes_scanned: aiR?.bytes_scanned ?? null,\n vulnerable_dep_count: depsR?.vulnerable_count ?? null,\n ai_concern_count: Array.isArray(aiR?.parsed?.concerns) ? aiR.parsed.concerns.length : null,\n registry: depsR?.registry ?? null,\n registry_available: depsR?.registry_available ?? null,\n not_implemented: depsR?.not_implemented ?? null,\n package_name: depsR?.package_name ?? null,\n version: depsR?.version ?? null,\n has_install_scripts: depsR?.has_install_scripts ?? null,\n install_scripts: depsR?.install_scripts ?? null,\n cross_check_mismatch_count: Array.isArray(depsR?.cross_check_mismatches) ? depsR.cross_check_mismatches.length : null,\n};\n\nreturn [{ json: { stage: 'summary', result: summary, status: 'completed' } }];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n return [{ json: { stage: \"summary\", result: { runAt: new Date().toISOString(), error: errMsg, passed: false }, status: \"failed\" } }];\n}" }, "onError": "continueRegularOutput" },