From 4a1a1845fe6859d861edcb8c40d6ba03b1196666 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Tue, 30 Jun 2026 09:04:24 -0700 Subject: [PATCH 1/8] feat(automation): tag deployed workflows (community-hub + env) in deploy script workflows:deploy now tags every workflow with community-hub plus the environment tag (staging, or production when namespace=strapi) via PUT /workflows/:id/tags, creating any missing tag. Applied to the staging set. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/automation/scripts/deploy-workflows.mjs | 48 +++++++++++++++++++- apps/automation/workflows/README.md | 6 ++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/automation/scripts/deploy-workflows.mjs b/apps/automation/scripts/deploy-workflows.mjs index a87ee1e..88e409c 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 })) { @@ -214,7 +241,26 @@ async function main() { } } - console.log(`\nSummary: ${created} created, ${updated} updated, ${relinked} re-linked, ${failed} failed.`); + // 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 [name, id] of ids) { + try { + await api('PUT', `/api/v1/workflows/${id}/tags`, body); + tagged++; + } catch (e) { + console.error(` tag failed ${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, ${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 From 2bfbd4ee0291f6eecefb2ba93980b4a3b06d57ce Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Tue, 30 Jun 2026 09:52:22 -0700 Subject: [PATCH 2/8] fix(automation): security-scan summary reads stage results from Format nodes Build Summary read the 'Wait for All Stages' merge output (which combines the two PATCH responses and has no stage/result keys), so its pick() returned null for both stages and every summary field came out null. Read the dependency + AI results directly from the Format Package Scan Result / Format AI Result nodes instead; the merge stays only to gate timing. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/automation/workflows/security-scan/workflow.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, From c49c0e2e8815b3668907136b3965ae4a3e2871cc Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Tue, 30 Jun 2026 09:52:22 -0700 Subject: [PATCH 3/8] fix(automation): deploy re-links before PUT + tags only deployed workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-link executeWorkflow/errorWorkflow references to the target's ids BEFORE the create/update (push referenced sub-workflows first, seed ids from existing) — n8n rejects publishing an active workflow whose executeWorkflow target isn't published, so the old relink-in-a-second-pass failed on re-deploys against active instances. Also scope the tag pass to the deployed workflows only, so pre-existing workflows on a shared instance aren't tagged/overwritten. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/automation/scripts/deploy-workflows.mjs | 46 ++++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/automation/scripts/deploy-workflows.mjs b/apps/automation/scripts/deploy-workflows.mjs index 88e409c..c5d6289 100644 --- a/apps/automation/scripts/deploy-workflows.mjs +++ b/apps/automation/scripts/deploy-workflows.mjs @@ -202,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)); @@ -225,34 +239,20 @@ 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)) { - try { - await api('PUT', `/api/v1/workflows/${ids.get(wf.name)}`, sanitize(wf)); - relinked++; - } catch (e) { - console.error(` relink failed ${wf.name}: ${e.message}`); - } - } - } - // 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 [name, id] of ids) { + 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/${id}/tags`, body); tagged++; } catch (e) { - console.error(` tag failed ${name}: ${e.message}`); + console.error(` tag failed ${wf.name}: ${e.message}`); } } console.log(` tagged ${tagged} workflows: community-hub + ${envTag}`); @@ -260,7 +260,7 @@ async function main() { console.error(` tagging skipped: ${e.message}`); } - console.log(`\nSummary: ${created} created, ${updated} updated, ${relinked} re-linked, ${tagged} tagged, ${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); } From 487c8c271ca5ff7568d2ec26761de0b98e336ae6 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Tue, 30 Jun 2026 10:01:19 -0700 Subject: [PATCH 4/8] fix(automation): render email body markdown to HTML in render-email The email-template body is Strapi richtext (markdown), but render-email dropped it raw into the HTML shell, so **bold**, > blockquotes, and paragraph breaks showed as literal text. Add a markdown->HTML converter in 'Wrap In Branded Shell' (bold/italic/inline-code/blockquote/headings/paragraphs + bare-URL autolink), HTML-escaping first so interpolated reviewer feedback can't inject markup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/render-email-shared-sub-workflow/workflow.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 You're receiving this because you interacted with the Strapi community hub.\n
\n\n`;\n\nreturn [{ json: { ...item, html_body: shell } }];" } }, { From 6f2e5963c59ea65ffedf287c58e6213fc33bb9ca Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Tue, 30 Jun 2026 20:16:48 +0200 Subject: [PATCH 5/8] feat: add more rich labellign --- apps/cms/src/components/shared/labels.json | 4 + apps/cms/types/generated/components.d.ts | 1 + apps/web/src/components/content/card/card.tsx | 25 +++--- .../src/components/content/label-icons.tsx | 82 +++++++++++++++++++ .../features/cms/pages/package/template.tsx | 2 + .../features/cms/pages/template/template.tsx | 2 + 6 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/content/label-icons.tsx diff --git a/apps/cms/src/components/shared/labels.json b/apps/cms/src/components/shared/labels.json index 209af08..a670124 100644 --- a/apps/cms/src/components/shared/labels.json +++ b/apps/cms/src/components/shared/labels.json @@ -13,6 +13,10 @@ "featured": { "type": "boolean", "default": false + }, + "recommended": { + "type": "boolean", + "default": false } }, "config": {} diff --git a/apps/cms/types/generated/components.d.ts b/apps/cms/types/generated/components.d.ts index 21aa9d3..326f52c 100644 --- a/apps/cms/types/generated/components.d.ts +++ b/apps/cms/types/generated/components.d.ts @@ -137,6 +137,7 @@ export interface SharedLabels extends Struct.ComponentSchema { attributes: { featured: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; official: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; + recommended: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; }; } diff --git a/apps/web/src/components/content/card/card.tsx b/apps/web/src/components/content/card/card.tsx index 1256876..cbfb7e8 100644 --- a/apps/web/src/components/content/card/card.tsx +++ b/apps/web/src/components/content/card/card.tsx @@ -1,8 +1,9 @@ import type { Data } from "@strapi/types"; -import { BadgeCheck, Download, Github, Star } from "lucide-react"; +import { Download, Github, Star } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { AvatarPile } from "@/components/content/avatar-pile"; +import { ContentLabels } from "@/components/content/label-icons"; import type { Owner } from "@/utils/types"; type Props = { @@ -44,19 +45,21 @@ const ContentCard = (props: Props) => { return ( -
+
{/* Preview area */}
{image ? ( isLarge ? ( - {image.alt +
+ {image.alt +
) : ( {

{name}

- {labels?.official && ( - - )} +
{/* Description */} diff --git a/apps/web/src/components/content/label-icons.tsx b/apps/web/src/components/content/label-icons.tsx new file mode 100644 index 0000000..e3f2a82 --- /dev/null +++ b/apps/web/src/components/content/label-icons.tsx @@ -0,0 +1,82 @@ +import type { Data } from "@strapi/types"; +import { BadgeCheck, Star } from "lucide-react"; + +const LabelTooltip = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( + + {children} + + {label} + + +); + +const StrapiLogo = ({ className }: { className?: string }) => ( + +); + +type ContentLabelsProps = { + labels?: Data.Component<"shared.labels"> | null; + size?: "sm" | "md"; +}; + +const ContentLabels = ({ labels, size = "md" }: ContentLabelsProps) => { + const iconClass = size === "sm" ? "h-4 w-4" : "h-6 w-6"; + + return ( + <> + {labels?.official && ( + + + + )} + {labels?.recommended && ( + + + + )} + {labels?.featured && ( + + + + )} + + ); +}; + +export { ContentLabels, LabelTooltip, StrapiLogo }; diff --git a/apps/web/src/features/cms/pages/package/template.tsx b/apps/web/src/features/cms/pages/package/template.tsx index b811586..0b96644 100644 --- a/apps/web/src/features/cms/pages/package/template.tsx +++ b/apps/web/src/features/cms/pages/package/template.tsx @@ -5,6 +5,7 @@ import Image from "next/image"; import Link from "next/link"; import { AvatarPile } from "@/components/content/avatar-pile"; import { GitProviderLogo } from "@/components/content/git-provider-logo"; +import { ContentLabels } from "@/components/content/label-icons"; import { Markdown } from "@/components/content/markdown"; import { RegistryLogo } from "@/components/content/registry-logo"; import { SidebarSection } from "@/components/content/sidebar-section"; @@ -59,6 +60,7 @@ const PackageTemplate = ({ document, communityCta }: Props) => {

{document.name}

+ {owner && (

diff --git a/apps/web/src/features/cms/pages/template/template.tsx b/apps/web/src/features/cms/pages/template/template.tsx index dc5a433..57694cc 100644 --- a/apps/web/src/features/cms/pages/template/template.tsx +++ b/apps/web/src/features/cms/pages/template/template.tsx @@ -5,6 +5,7 @@ import Image from "next/image"; import Link from "next/link"; import { AvatarPile } from "@/components/content/avatar-pile"; import { GitProviderLogo } from "@/components/content/git-provider-logo"; +import { ContentLabels } from "@/components/content/label-icons"; import { Markdown } from "@/components/content/markdown"; import { SidebarSection } from "@/components/content/sidebar-section/sidebar-section"; import { Navigation } from "@/components/layout/navigation"; @@ -56,6 +57,7 @@ const TemplateTemplate = ({ document, communityCta }: Props) => {

{document.name}

+ {owner && (

From 5385c35be6d3f4227330990755a47a809c449f09 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Tue, 30 Jun 2026 11:20:01 -0700 Subject: [PATCH 6/8] fix(automation): scope deploy matching by env tag so prod doesn't clobber staging deploy-workflows.mjs matched existing workflows by name globally. With staging and production sharing one n8n instance and carrying identical workflow names, a production deploy would update the staging set in place instead of creating a separate set. Scope the create-vs-update decision to the environment tag (staging/production): a first deploy to a new environment creates a fresh set, and re-deploys update only that environment's set, leaving the other untouched. Document the tag-keying in the workflows README. Co-Authored-By: Claude Opus 4.8 --- apps/automation/scripts/deploy-workflows.mjs | 23 +++++++++++++++----- apps/automation/workflows/README.md | 7 ++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/automation/scripts/deploy-workflows.mjs b/apps/automation/scripts/deploy-workflows.mjs index c5d6289..2da4ce1 100644 --- a/apps/automation/scripts/deploy-workflows.mjs +++ b/apps/automation/scripts/deploy-workflows.mjs @@ -48,6 +48,10 @@ const N8N_URL = (process.env.N8N_URL || 'http://localhost:5678').replace(/\/$/, const API_KEY = process.env.N8N_API_KEY; const STRAPI_BASE_URL = (process.env.STRAPI_BASE_URL || '').replace(/\/$/, ''); const NAMESPACE = process.env.N8N_WEBHOOK_NAMESPACE || 'strapi'; +// The environment tag identifies this deploy's workflow set on the shared instance. +// Matching is scoped to it so staging and production sets (same names, different tags) +// never collide: a production deploy only ever updates production-tagged workflows. +const ENV_TAG = NAMESPACE === 'strapi' ? 'production' : NAMESPACE; if (!API_KEY) { console.error('Error: N8N_API_KEY is not set (target instance key).'); @@ -207,7 +211,17 @@ async function main() { // 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])); + // Scope existing-workflow matching to THIS environment's tag, so duplicate sets on + // one instance (same names, different env tag) stay independent. On a first deploy to + // a new environment this is empty -> every workflow is created fresh; on a re-deploy it + // matches only this env's set -> updates in place and leaves the other set untouched. + const remote = await listWorkflows(); + const inEnv = remote.filter((w) => (w.tags ?? []).some((t) => t.name === ENV_TAG)); + console.log( + ` found ${inEnv.length} existing '${ENV_TAG}'-tagged workflow(s) on target ` + + `(${remote.length} total on instance)\n`, + ); + const existing = new Map(inEnv.map((w) => [w.name, w.id])); const ids = new Map(existing); const byName = new Map(local.map((w) => [w.name, w])); let created = 0, updated = 0, failed = 0; @@ -240,11 +254,10 @@ async function main() { } // 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) }]; + const tagIds = await resolveTags(['community-hub', ENV_TAG]); + const body = [{ id: tagIds.get('community-hub') }, { id: tagIds.get(ENV_TAG) }]; for (const wf of order) { // only the workflows we deployed, never pre-existing ones const id = ids.get(wf.name); if (!id) continue; @@ -255,7 +268,7 @@ async function main() { console.error(` tag failed ${wf.name}: ${e.message}`); } } - console.log(` tagged ${tagged} workflows: community-hub + ${envTag}`); + console.log(` tagged ${tagged} workflows: community-hub + ${ENV_TAG}`); } catch (e) { console.error(` tagging skipped: ${e.message}`); } diff --git a/apps/automation/workflows/README.md b/apps/automation/workflows/README.md index 4c57af2..c971dc4 100644 --- a/apps/automation/workflows/README.md +++ b/apps/automation/workflows/README.md @@ -95,6 +95,13 @@ placeholder to `STRAPI_BASE_URL`, (2) rewrites webhook paths to (4) tags every workflow `community-hub` + the environment tag (`staging`, or `production` when the namespace is `strapi`), creating the tags if missing. +> **Sets are keyed by the environment tag, not by name.** The staging and production +> sets carry identical workflow names, so the deploy decides "create vs update" by the +> **env tag** (`staging`/`production`), never by name. A production deploy only ever +> updates `production`-tagged workflows — it creates a fresh set on first run and leaves +> the staging set untouched. (The two coexist by tag + folder; n8n allows duplicate +> names.) This is why the tags matter operationally — don't strip them. + > **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 > does **not** re-link — fine for `localhost:5678` against a local Strapi. From f8f18dc78fa2cd16a9d941be37dc2f55be0bea35 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Tue, 30 Jun 2026 20:28:21 +0200 Subject: [PATCH 7/8] fix: avatar pile organization logo --- .../content/avatar-pile/avatar-pile.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/content/avatar-pile/avatar-pile.tsx b/apps/web/src/components/content/avatar-pile/avatar-pile.tsx index 3f4b255..040a10e 100644 --- a/apps/web/src/components/content/avatar-pile/avatar-pile.tsx +++ b/apps/web/src/components/content/avatar-pile/avatar-pile.tsx @@ -2,9 +2,10 @@ import type { Data } from "@strapi/types"; import Image from "next/image"; import Link from "next/link"; import { cmsImageUrl } from "@/features/cms/lib/image-url"; +import type { Owner } from "@/utils/types"; type Props = { - items: Data.ContentType<"plugin::better-auth.user">[]; + items: Owner[]; size?: "S" | "L"; clickable?: boolean; white?: boolean; @@ -14,11 +15,7 @@ type Props = { // 5 slots: 18 + 24 + 32 + 24 + 18 = 116%; 4 overlaps × 6% = 24% → net ≈ 92%. const SLOT_WIDTHS = ["18%", "24%", "32%", "24%", "18%"]; -const AvatarLarge = ({ - items, -}: { - items: Data.ContentType<"plugin::better-auth.user">[]; -}) => { +const AvatarLarge = ({ items }: { items: Owner[] }) => { const slots = items.filter(Boolean).slice(0, 5); const centerIdx = Math.floor((slots.length - 1) / 2); @@ -29,7 +26,9 @@ const AvatarLarge = ({ const configIdx = 2 + distFromCenter; const width = SLOT_WIDTHS[configIdx] ?? "18%"; const zIndex = 10 - Math.abs(distFromCenter); - const avatarUrl = m.image; + const imageUrl = "image" in m ? m.image : undefined; + const logoUrl = "logo" in m ? m.logo : undefined; + const avatarUrl = (imageUrl || logoUrl) as string; return (

{ .filter(Boolean) .slice(0, 5) .map((m, i) => { - const avatarUrl = m.image; + const imageUrl = "image" in m ? m.image : undefined; + const logoUrl = "logo" in m ? m.logo : undefined; + const avatarUrl = (imageUrl || logoUrl) as string; + return avatarUrl ? ( Date: Wed, 1 Jul 2026 12:36:51 +0200 Subject: [PATCH 8/8] feat: allow manual selection in the highlights component --- .../src/components/sections/highlights.json | 44 ++++++++++++++++++- apps/cms/types/generated/components.d.ts | 9 ++++ apps/web/src/features/cms/pages/home/page.tsx | 25 ++++++++++- .../cms/sections/highlights/highlights.tsx | 28 +++++++++++- 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/apps/cms/src/components/sections/highlights.json b/apps/cms/src/components/sections/highlights.json index c3fdee1..a19e924 100644 --- a/apps/cms/src/components/sections/highlights.json +++ b/apps/cms/src/components/sections/highlights.json @@ -20,10 +20,13 @@ "enum": [ "packages_highlighted", "packages_newest", + "packages_selection", "templates_highlighted", "templates_newest", + "templates_selection", "integrations_highlighted", "integrations_newest", + "integrations_selection", "recipes_highlighted", "recipes_newest", "showcases_highlighted", @@ -37,7 +40,16 @@ "required": true, "default": 2, "min": 2, - "max": 6 + "max": 6, + "conditions": { + "visible": { + "and": [ + { "!=": [{ "var": "query" }, "packages_selection"] }, + { "!=": [{ "var": "query" }, "templates_selection"] }, + { "!=": [{ "var": "query" }, "integrations_selection"] } + ] + } + } }, "grid": { "type": "integer", @@ -45,6 +57,36 @@ "default": 2, "min": 2, "max": 4 + }, + "packages": { + "type": "relation", + "relation": "oneToMany", + "target": "api::package.package", + "conditions": { + "visible": { + "==": [{ "var": "query" }, "packages_selection"] + } + } + }, + "templates": { + "type": "relation", + "relation": "oneToMany", + "target": "api::template.template", + "conditions": { + "visible": { + "==": [{ "var": "query" }, "templates_selection"] + } + } + }, + "integrations": { + "type": "relation", + "relation": "oneToMany", + "target": "api::integration.integration", + "conditions": { + "visible": { + "==": [{ "var": "query" }, "integrations_selection"] + } + } } }, "config": {} diff --git a/apps/cms/types/generated/components.d.ts b/apps/cms/types/generated/components.d.ts index 326f52c..e7569ab 100644 --- a/apps/cms/types/generated/components.d.ts +++ b/apps/cms/types/generated/components.d.ts @@ -72,14 +72,22 @@ export interface SectionsHighlights extends Struct.ComponentSchema { number > & Schema.Attribute.DefaultTo<2>; + integrations: Schema.Attribute.Relation< + 'oneToMany', + 'api::integration.integration' + >; + packages: Schema.Attribute.Relation<'oneToMany', 'api::package.package'>; query: Schema.Attribute.Enumeration< [ 'packages_highlighted', 'packages_newest', + 'packages_selection', 'templates_highlighted', 'templates_newest', + 'templates_selection', 'integrations_highlighted', 'integrations_newest', + 'integrations_selection', 'recipes_highlighted', 'recipes_newest', 'showcases_highlighted', @@ -89,6 +97,7 @@ export interface SectionsHighlights extends Struct.ComponentSchema { ] > & Schema.Attribute.Required; + templates: Schema.Attribute.Relation<'oneToMany', 'api::template.template'>; title: Schema.Attribute.String & Schema.Attribute.Required; }; } diff --git a/apps/web/src/features/cms/pages/home/page.tsx b/apps/web/src/features/cms/pages/home/page.tsx index f205957..9265547 100644 --- a/apps/web/src/features/cms/pages/home/page.tsx +++ b/apps/web/src/features/cms/pages/home/page.tsx @@ -21,7 +21,30 @@ const query = { }, }, }, - "sections.highlights": true, + "sections.highlights": { + populate: { + button: true, + packages: { + populate: { + icon: true, + url_alias: true, + labels: true, + owner: true, + }, + }, + templates: { + populate: { + preview_image: true, + url_alias: true, + labels: true, + owner: true, + }, + }, + integrations: { + populate: { logo: true, url_alias: true, labels: true }, + }, + }, + }, }, }, }, diff --git a/apps/web/src/features/cms/sections/highlights/highlights.tsx b/apps/web/src/features/cms/sections/highlights/highlights.tsx index acd290d..a5aca18 100644 --- a/apps/web/src/features/cms/sections/highlights/highlights.tsx +++ b/apps/web/src/features/cms/sections/highlights/highlights.tsx @@ -84,7 +84,33 @@ async function fetchItems(query: string, amount: number) { const HighlightsSection = async ({ section }: Props) => { const { title, query, amount, grid, button } = section; - const { type, items } = await fetchItems(query!, amount!); + let result: Awaited>; + + if (query === "packages_selection") { + result = { + type: "package" as const, + items: + (section.packages as Data.ContentType<"api::package.package">[]) ?? [], + }; + } else if (query === "templates_selection") { + result = { + type: "template" as const, + items: + (section.templates as Data.ContentType<"api::template.template">[]) ?? + [], + }; + } else if (query === "integrations_selection") { + result = { + type: "integration" as const, + items: + (section.integrations as Data.ContentType<"api::integration.integration">[]) ?? + [], + }; + } else { + result = await fetchItems(query!, amount!); + } + + const { type, items } = result; const gridCols = gridColsMap[grid!] ?? gridColsMap[2];