Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 61 additions & 15 deletions apps/automation/scripts/deploy-workflows.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })) {
Expand Down Expand Up @@ -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));
Expand All @@ -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);
}
Expand Down
6 changes: 4 additions & 2 deletions apps/automation/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<namespace>/<event>`, and (3) re-points `executeWorkflow → render-email` and
`Settings → Error Workflow → error-handler` to the target instance's ids.
`<namespace>/<event>`, (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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
],
"parameters": {
"language": "javaScript",
"jsCode": "const item = $input.first().json;\n\nconst shell = `<!doctype html>\n<html>\n<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#212134;max-width:600px;margin:0 auto;padding:24px;\">\n <header style=\"border-bottom:1px solid #dcdce4;padding-bottom:16px;margin-bottom:24px;\">\n <strong style=\"color:#4945ff;\">Strapi Community</strong>\n </header>\n <main>${item.body}</main>\n <footer style=\"border-top:1px solid #dcdce4;margin-top:32px;padding-top:16px;font-size:12px;color:#666687;\">\n You're receiving this because you interacted with the Strapi community hub.\n </footer>\n</body>\n</html>`;\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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;');\n}\n\nfunction inline(s) {\n // s is already HTML-escaped; '>' is now '&gt;'\n return s\n .replace(/`([^`]+)`/g, '<code style=\"background:#f6f6f9;padding:1px 4px;border-radius:3px;\">$1</code>')\n .replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')\n .replace(/(^|[^*])\\*([^*\\n]+)\\*(?!\\*)/g, '$1<em>$2</em>')\n .replace(/(https?:\\/\\/[^\\s<]+)/g, '<a href=\"$1\" style=\"color:#4945ff;\">$1</a>');\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*&gt;\\s?/.test(l))) {\n const inner = lines.map((l) => l.replace(/^\\s*&gt;\\s?/, '')).join('<br>');\n return `<blockquote style=\"margin:0 0 16px;padding:8px 16px;border-left:3px solid #dcdce4;color:#666687;\">${inline(inner)}</blockquote>`;\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 `<h${lvl} style=\"margin:0 0 12px;\">${inline(h[2])}</h${lvl}>`;\n }\n return `<p style=\"margin:0 0 16px;line-height:1.5;\">${inline(lines.join('<br>'))}</p>`;\n }).join('\\n');\n}\n\nconst bodyHtml = markdownToHtml(item.body);\n\nconst shell = `<!doctype html>\n<html>\n<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#212134;max-width:600px;margin:0 auto;padding:24px;\">\n <header style=\"border-bottom:1px solid #dcdce4;padding-bottom:16px;margin-bottom:24px;\">\n <strong style=\"color:#4945ff;\">Strapi Community</strong>\n </header>\n <main>${bodyHtml}</main>\n <footer style=\"border-top:1px solid #dcdce4;margin-top:32px;padding-top:16px;font-size:12px;color:#666687;\">\n You're receiving this because you interacted with the Strapi community hub.\n </footer>\n</body>\n</html>`;\n\nreturn [{ json: { ...item, html_body: shell } }];"
}
},
{
Expand Down
2 changes: 1 addition & 1 deletion apps/automation/workflows/security-scan/workflow.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading