diff --git a/.github/workflows/cms-editorial-workflow.yml b/.github/workflows/cms-editorial-workflow.yml index 24eadae..b5d5356 100644 --- a/.github/workflows/cms-editorial-workflow.yml +++ b/.github/workflows/cms-editorial-workflow.yml @@ -4,6 +4,23 @@ on: secrets: CMS_E2E_PAT: required: false + inputs: + # Where the content-pr-guard step's validate-content sub-step fetches + # the Decap branch convention (e2e/cms-fixture-pr.js) and the guard + # module (scripts/content-pr-guard.js) from. Pin `platform_ref` to the + # same ref as the caller's `uses:` pin (same convention as + # label-non-decap-prs.yml) so the two never disagree about what + # "Decap-shaped" means. + platform_repo: + description: "owner/name of the platform repo holding e2e/cms-fixture-pr.js and scripts/content-pr-guard.js." + type: string + required: false + default: Adam-S-Daniel/cms-platform + platform_ref: + description: "Tag/sha of the platform repo to fetch the module from (pin to the same ref as the caller's uses: pin)." + type: string + required: false + default: main permissions: contents: read pull-requests: write @@ -44,6 +61,18 @@ permissions: # to SUCCESS → never a cancelled run → the context is unambiguously green. Cost: # a few extra ~30s runs per PR (each on its own — GitHub's repo-level limit # still queues them); cheap, and correctness wins. +# +# validate-content ALSO runs the content PR conformance guard (the "Content PR +# conformance guard" step below): PRs that touch CMS-managed content but were +# NOT opened by Decap fail this same check, with a PR comment explaining the +# restriction and its escape hatch (a maintainer applies the override label — +# see scripts/content-pr-guard.js's OVERRIDE_LABEL, deliberately not re-typed +# here so the two can't drift; the resulting `labeled` event re-runs this job +# and it then passes). No new required check and no ruleset change — it rides +# the existing required `editorial / validate-content` context. Callers +# should pin this workflow's `platform_ref` input to the same tag/sha as +# their `uses:` pin (see `on.workflow_call.inputs` above) so the guard's +# notion of "Decap-shaped" never drifts from the reusable's own version. jobs: validate-content: @@ -59,6 +88,156 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} + - name: Checkout platform module + # The Decap branch-prefix constant (e2e/cms-fixture-pr.js's + # FIXTURE_BRANCH_PREFIX) and the content-pr-guard module + # (scripts/content-pr-guard.js) both live in the platform, not the + # site repo. Fetch them from the pinned ref so the guard stays in + # lockstep with the labeller (label-non-decap-prs.yml) and the + # fixture harness on what "Decap-shaped" means. + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.2 (2026-01-09) + with: + repository: ${{ inputs.platform_repo }} + ref: ${{ inputs.platform_ref }} + path: .cms-platform + + - name: Content PR conformance guard + # FAIL FAST, before Ruby/Jekyll: PRs that touch CMS-managed content + # (posts, tags, projects, pages, e2e fixtures, uploaded media — see + # scripts/content-pr-guard.js's BASE_CONTENT_DIRS + this site's + # admin/collections.site.yml) must have been opened BY Decap CMS, + # not by hand. A non-Decap edit to those paths skips the entry's + # draft/review/ready status, its stable preview alias, and publish/ + # schedule semantics — and can collide with Decap's own on-repo + # state. This step fails the already-required `validate-content` + # check (no new required check, no ruleset change) and leaves a + # comment explaining the restriction and the escape hatch: a + # maintainer can apply the override label (OVERRIDE_LABEL, sourced + # from scripts/content-pr-guard.js below — not re-typed here) for + # deliberate maintenance (fixture repair, bulk migration) — the + # `labeled` event re-runs this check and it then passes. + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (2026-04-09) + with: + script: | + const path = require('path'); + const fs = require('fs'); + + // Source the Decap branch convention from the platform module so + // this guard stays in lockstep with label-non-decap-prs.yml and + // the fixture harness (same pattern as that workflow). + const { FIXTURE_BRANCH_PREFIX } = require( + path.join(process.env.GITHUB_WORKSPACE, '.cms-platform', 'e2e', 'cms-fixture-pr.js'), + ); + const DECAP_BRANCH_PREFIX = `${FIXTURE_BRANCH_PREFIX.split('/')[0]}/`; + + const { evaluateContentGuard, COMMENT_MARKER, OVERRIDE_LABEL } = require( + path.join(process.env.GITHUB_WORKSPACE, '.cms-platform', 'scripts', 'content-pr-guard.js'), + ); + + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + const changedFiles = files.map((f) => f.filename); + + // The site's site-specific collections (if any) — folded into + // BASE_CONTENT_DIRS by evaluateContentGuard. Absent on a + // single-collection site, so a missing file is not an error. + let seamYamlText = null; + try { + seamYamlText = fs.readFileSync('admin/collections.site.yml', 'utf8'); + } catch (_) { + seamYamlText = null; + } + + // Derive the site's /admin URL from its own _config.yml so this + // guard never hardcodes a site's identity. Falls back to the + // bare path if `url:` isn't found. + let adminUrl = '/admin/'; + try { + const configText = fs.readFileSync('_config.yml', 'utf8'); + const head = configText.split('\n').slice(0, 50).join('\n'); + const match = head.match(/^url:\s*(.+?)\s*$/m); + if (match) { + const url = match[1].replace(/^["']|["']$/g, '').trim().replace(/\/+$/, ''); + if (url) adminUrl = `${url}/admin/`; + } + } catch (_) { + /* fall back to the bare path */ + } + + const result = evaluateContentGuard({ + pr, + changedFiles, + seamYamlText, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl, + }); + + // All comment/label API work is wrapped so a GitHub API hiccup + // here can never flip the verdict computed above. + try { + // Ensure the override label exists so a maintainer can apply + // it from the PR UI (same createLabel-in-try/catch pattern as + // the cms/draft + cms/ready labels below in this file). + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: OVERRIDE_LABEL, + color: '5319e7', + description: 'Maintainer override: allow non-Decap content changes in this PR', + }); + } catch (_) {} + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pr.number, + per_page: 100, + }); + const existing = comments.find((c) => (c.body || '').includes(COMMENT_MARKER)); + + if (result.verdict === 'fail') { + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: result.commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: result.commentBody, + }); + } + } else if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: `${COMMENT_MARKER}\n✅ Content guard passed at ${pr.head.sha} (${result.reason}).`, + }); + } + } catch (err) { + core.warning(`content-pr-guard: comment/label upsert failed (${err.message}); verdict unaffected.`); + } + + if (result.verdict === 'fail') { + core.info(`content-pr-guard: non-Decap content change — offending file(s):\n${result.contentFiles.join('\n')}`); + core.setFailed( + `Non-Decap PR touches ${result.contentFiles.length} CMS-managed content file(s) — see the PR comment for why, and the ${OVERRIDE_LABEL} escape hatch.`, + ); + } + - name: Setup Ruby uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.310.0 (2026-05-20) with: diff --git a/e2e/content-pr-guard.test.js b/e2e/content-pr-guard.test.js new file mode 100644 index 0000000..32f439a --- /dev/null +++ b/e2e/content-pr-guard.test.js @@ -0,0 +1,290 @@ +// @lane: local — pure-fs unit + wiring lint for the content PR conformance guard. +// +// Goal: PRs that touch CMS-managed content paths but were NOT created by +// Decap must fail the already-required `editorial / validate-content` +// check, with a clear PR comment explaining the restriction and the +// escape hatch (the `content-guard/override` label). No new required +// check, no ruleset change. +// +// This file covers two layers: +// 1. UNIT — scripts/content-pr-guard.js's pure decision logic, driven +// directly with synthetic PR/file/YAML fixtures. The Decap branch +// prefix is derived from e2e/cms-fixture-pr.js's FIXTURE_BRANCH_PREFIX +// exactly as cms-editorial-workflow.yml's guard step does, so a +// change to that constant can't silently desync the two. +// 2. WIRING — cms-editorial-workflow.yml actually calls the module the +// way the unit tests assume: workflow_call inputs exist with the +// right defaults, validate-content still has NO `concurrency` key +// (#1815), the platform-module checkout + github-script steps are +// present, action pins match the rest of the file byte-for-byte, and +// the override label name is NOT duplicated as a literal in the +// workflow (it must come from the module, not be re-typed). +const { test, expect } = require("./base"); +const { readWorkflow, parseYaml, jobs } = require("./workflow-yaml-utils"); +const { + COMMENT_MARKER, + OVERRIDE_LABEL, + DECAP_BODY_MARKER, + seamContentDirs, + isDecapShaped, + evaluateContentGuard, +} = require("../scripts/content-pr-guard.js"); +const { FIXTURE_BRANCH_PREFIX } = require("./cms-fixture-pr.js"); +const fs = require("node:fs"); +const path = require("node:path"); + +// Derived exactly as the workflow's "Content PR conformance guard" step +// derives it — the lockstep proof. +const DECAP_BRANCH_PREFIX = `${FIXTURE_BRANCH_PREFIX.split("/")[0]}/`; + +const ADMIN_URL = "https://example.test/admin/"; + +function pr({ ref = "feature/some-branch", body = "", labels = [], sha = "abc1234" } = {}) { + return { number: 42, head: { ref, sha }, body, labels }; +} + +test.describe("content-pr-guard unit — decision logic", () => { + test("non-cms branch + _posts change fails, with a complete comment", () => { + const result = evaluateContentGuard({ + pr: pr({ ref: "claude/some-fix", body: "manual edit" }), + changedFiles: ["_posts/2026-01-01-hello.md", "README.md"], + seamYamlText: null, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl: ADMIN_URL, + }); + + expect(result.verdict).toBe("fail"); + expect(result.reason).toBe("non-decap-content-change"); + expect(result.contentFiles).toEqual(["_posts/2026-01-01-hello.md"]); + expect(result.commentBody.startsWith(COMMENT_MARKER)).toBe(true); + expect(result.commentBody).toContain("_posts/2026-01-01-hello.md"); + expect(result.commentBody).toContain(ADMIN_URL); + expect(result.commentBody).toContain(OVERRIDE_LABEL); + }); + + test("cms/-prefixed head branch passes as decap-shaped", () => { + const result = evaluateContentGuard({ + pr: pr({ ref: `${DECAP_BRANCH_PREFIX}posts/hello`, body: "" }), + changedFiles: ["_posts/2026-01-01-hello.md"], + seamYamlText: null, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl: ADMIN_URL, + }); + expect(result.verdict).toBe("pass"); + expect(result.reason).toBe("decap-shaped"); + }); + + test("Decap body-marker-only PR passes as decap-shaped", () => { + const result = evaluateContentGuard({ + pr: pr({ ref: "some-random-branch", body: `${DECAP_BODY_MARKER}\n\nrest of body` }), + changedFiles: ["_posts/2026-01-01-hello.md"], + seamYamlText: null, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl: ADMIN_URL, + }); + expect(result.verdict).toBe("pass"); + expect(result.reason).toBe("decap-shaped"); + }); + + test("decap-cms/draft label passes as decap-shaped", () => { + const result = evaluateContentGuard({ + pr: pr({ ref: "some-random-branch", body: "", labels: [{ name: "decap-cms/draft" }] }), + changedFiles: ["_posts/2026-01-01-hello.md"], + seamYamlText: null, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl: ADMIN_URL, + }); + expect(result.verdict).toBe("pass"); + expect(result.reason).toBe("decap-shaped"); + }); + + test("content-guard/override label passes with reason override", () => { + const result = evaluateContentGuard({ + pr: pr({ ref: "some-random-branch", body: "", labels: [{ name: OVERRIDE_LABEL }] }), + changedFiles: ["_posts/2026-01-01-hello.md"], + seamYamlText: null, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl: ADMIN_URL, + }); + expect(result.verdict).toBe("pass"); + expect(result.reason).toBe("override"); + }); + + test("a non-content diff passes regardless of provenance", () => { + const result = evaluateContentGuard({ + pr: pr({ ref: "claude/some-fix", body: "" }), + changedFiles: ["README.md", "scripts/foo.js"], + seamYamlText: null, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl: ADMIN_URL, + }); + expect(result.verdict).toBe("pass"); + expect(result.reason).toBe("no-content-changes"); + expect(result.contentFiles).toEqual([]); + }); + + test("uploaded media path counts as content", () => { + const result = evaluateContentGuard({ + pr: pr({ ref: "claude/some-fix" }), + changedFiles: ["assets/images/uploads/photo.png"], + seamYamlText: null, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl: ADMIN_URL, + }); + expect(result.verdict).toBe("fail"); + expect(result.contentFiles).toEqual(["assets/images/uploads/photo.png"]); + }); + + test("a site's seam-derived collection folder is flagged as content", () => { + const seamYamlText = [ + " - name: notes", + " label: Notes", + " folder: _notes", + " fields:", + " - { name: title, label: Title, widget: string, required: true }", + "", + ].join("\n"); + expect(seamContentDirs(seamYamlText)).toEqual(["_notes/"]); + + const result = evaluateContentGuard({ + pr: pr({ ref: "claude/some-fix" }), + changedFiles: ["_notes/a.md"], + seamYamlText, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl: ADMIN_URL, + }); + expect(result.verdict).toBe("fail"); + expect(result.contentFiles).toEqual(["_notes/a.md"]); + }); + + test("seamContentDirs returns [] for null/empty input", () => { + expect(seamContentDirs(null)).toEqual([]); + expect(seamContentDirs("")).toEqual([]); + }); + + test("extraContentDirs is honored", () => { + const result = evaluateContentGuard({ + pr: pr({ ref: "claude/some-fix" }), + changedFiles: ["docs/x.md"], + seamYamlText: null, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + extraContentDirs: ["docs/"], + adminUrl: ADMIN_URL, + }); + expect(result.verdict).toBe("fail"); + expect(result.contentFiles).toEqual(["docs/x.md"]); + }); + + test("file list caps at 20 with a '+N more' suffix", () => { + const changedFiles = Array.from({ length: 25 }, (_, i) => `_posts/post-${i}.md`); + const result = evaluateContentGuard({ + pr: pr({ ref: "claude/some-fix" }), + changedFiles, + seamYamlText: null, + decapBranchPrefix: DECAP_BRANCH_PREFIX, + adminUrl: ADMIN_URL, + }); + expect(result.verdict).toBe("fail"); + expect(result.contentFiles.length).toBe(25); + for (let i = 0; i < 20; i++) { + expect(result.commentBody).toContain(`_posts/post-${i}.md`); + } + for (let i = 20; i < 25; i++) { + expect(result.commentBody).not.toContain(`\`_posts/post-${i}.md\``); + } + expect(result.commentBody).toMatch(/\+5 more/); + }); + + test("isDecapShaped mirrors the label-non-decap-prs.yml triad", () => { + expect(isDecapShaped(pr({ ref: `${DECAP_BRANCH_PREFIX}posts/x` }), DECAP_BRANCH_PREFIX)).toBe(true); + expect(isDecapShaped(pr({ body: DECAP_BODY_MARKER }), DECAP_BRANCH_PREFIX)).toBe(true); + expect( + isDecapShaped(pr({ labels: [{ name: "decap-cms/pending_review" }] }), DECAP_BRANCH_PREFIX), + ).toBe(true); + expect(isDecapShaped(pr({ ref: "claude/x", body: "" }), DECAP_BRANCH_PREFIX)).toBe(false); + }); +}); + +test.describe("content-pr-guard wiring — cms-editorial-workflow.yml", () => { + const raw = readWorkflow("cms-editorial-workflow.yml"); + const parsed = parseYaml(raw); + + test("workflow_call declares platform_repo/platform_ref with the expected defaults", () => { + const inputs = parsed.on.workflow_call.inputs; + expect(inputs).toBeTruthy(); + expect(inputs.platform_repo.type).toBe("string"); + expect(inputs.platform_repo.default).toBe("Adam-S-Daniel/cms-platform"); + expect(inputs.platform_ref.type).toBe("string"); + expect(inputs.platform_ref.default).toBe("main"); + }); + + test("validate-content has NO concurrency key (#1815 invariant)", () => { + const job = jobs(raw).find((j) => j.name === "validate-content"); + expect(job).toBeTruthy(); + expect(Object.prototype.hasOwnProperty.call(job.value, "concurrency")).toBe(false); + }); + + test("validate-content checks out the platform module into .cms-platform", () => { + const job = jobs(raw).find((j) => j.name === "validate-content"); + const step = (job.value.steps || []).find( + (s) => s.with && s.with.repository === "${{ inputs.platform_repo }}", + ); + expect(step).toBeTruthy(); + expect(step.with.ref).toBe("${{ inputs.platform_ref }}"); + expect(step.with.path).toBe(".cms-platform"); + }); + + test("validate-content has a github-script step requiring content-pr-guard.js and cms-fixture-pr.js", () => { + const job = jobs(raw).find((j) => j.name === "validate-content"); + const step = (job.value.steps || []).find( + (s) => + s.uses && + s.uses.startsWith("actions/github-script@") && + s.with && + typeof s.with.script === "string" && + s.with.script.includes("content-pr-guard.js"), + ); + expect(step).toBeTruthy(); + expect(step.with.script).toContain("cms-fixture-pr.js"); + expect(step.with.script).toContain("evaluateContentGuard"); + }); + + test("the guard's action pins are byte-identical to the rest of the file", () => { + const usesLines = [...raw.matchAll(/^\s*uses:\s*(actions\/(?:checkout|github-script)@\S+(?:\s+#.*)?)\s*$/gm)].map( + (m) => m[1].trim(), + ); + const checkoutPins = new Set(usesLines.filter((u) => u.startsWith("actions/checkout@"))); + const scriptPins = new Set(usesLines.filter((u) => u.startsWith("actions/github-script@"))); + expect(checkoutPins.size).toBe(1); + expect(scriptPins.size).toBe(1); + }); + + test("the raw workflow text does not hardcode the override label literal", () => { + // Forces the label name to be sourced from scripts/content-pr-guard.js + // (via the destructured OVERRIDE_LABEL) rather than re-typed, so the + // two can never drift. + expect(raw).not.toContain("content-guard/override"); + }); +}); + +test.describe("content-pr-guard wiring — example caller", () => { + test("examples/site pins platform_ref to match its uses: ref", () => { + const callerPath = path.resolve( + __dirname, + "..", + "examples", + "site", + ".github", + "workflows", + "cms-editorial-workflow.yml", + ); + const raw = fs.readFileSync(callerPath, "utf8"); + const parsed = parseYaml(raw); + const job = parsed.jobs.editorial; + const usesMatch = job.uses.match(/@([^@]+)$/); + expect(usesMatch).toBeTruthy(); + const pinnedRef = usesMatch[1]; + expect(job.with).toBeTruthy(); + expect(job.with.platform_ref).toBe(pinnedRef); + }); +}); diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 2705623..fc251a6 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -92,6 +92,10 @@ const PLATFORM_META_SPECS = [ // Reads the editorial-label-audit reusable workflow DEFINITION (consumer // ships only a wrapper) — platform-internal, self-CI only. "editorial-label-audit-repo.test.js", + // Reads scripts/content-pr-guard.js and the cms-editorial-workflow.yml + // reusable DEFINITION (via readWorkflow) plus the examples/site caller — + // platform-internal, self-CI only. + "content-pr-guard.test.js", // Reads the scheduled-run-health reusable + caller DEFINITIONS and the // scripts/audit-scheduled-runs.js helpers (consumer ships only a thin // wrapper) — platform-internal, self-CI only. diff --git a/examples/site/.github/workflows/cms-editorial-workflow.yml b/examples/site/.github/workflows/cms-editorial-workflow.yml index afed55b..035fd90 100644 --- a/examples/site/.github/workflows/cms-editorial-workflow.yml +++ b/examples/site/.github/workflows/cms-editorial-workflow.yml @@ -10,5 +10,8 @@ permissions: jobs: editorial: uses: Adam-S-Daniel/cms-platform/.github/workflows/cms-editorial-workflow.yml@v0.1.1 + with: + # Pin to the SAME ref as the `uses:` pin above so the module matches. + platform_ref: v0.1.1 secrets: CMS_E2E_PAT: ${{ secrets.CMS_E2E_PAT }} diff --git a/scripts/content-pr-guard.js b/scripts/content-pr-guard.js new file mode 100644 index 0000000..069b820 --- /dev/null +++ b/scripts/content-pr-guard.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node +// Content PR conformance guard — pure logic (no network, no octokit, no fs). +// +// WHY: Decap's editorial workflow is a contract, not just a UI convenience — +// every content change is ONE entry on a `cms//` branch, +// opened by Decap itself, carrying a `decap-cms/` label that drives +// the draft → review → ready → publish pipeline (cms-editorial-workflow.yml), +// a stable per-entry preview alias, and publish/schedule semantics. A PR that +// edits the same content paths WITHOUT going through /admin skips all of +// that: there's no draft/review status, no preview alias, and (for posts) no +// guarantee `published` / `publish_date` mean what the CMS expects. Worse, it +// can race Decap's own on-repo state (labels, branch naming, in-flight +// entries) since Decap doesn't know the edit happened. +// +// This module decides, for a single PR, whether its diff touches CMS-managed +// content and — if so — whether the PR is Decap-shaped (or has been granted +// an explicit override). The caller (cms-editorial-workflow.yml's +// validate-content job) wires this to the GitHub API: listing changed files, +// reading the site's seam YAML + `_config.yml` off the checked-out tree, and +// posting/updating a PR comment with the verdict. Keeping all of that out of +// this module means the decision logic is testable with plain objects and +// strings (see e2e/content-pr-guard.test.js) and has nothing to mock. +// +// Usage (from a github-script step): +// const { evaluateContentGuard } = require('.cms-platform/scripts/content-pr-guard.js'); +// const result = evaluateContentGuard({ pr, changedFiles, seamYamlText, decapBranchPrefix, adminUrl }); +"use strict"; + +// Marks the PR comment this guard owns so re-runs update it in place instead +// of piling up duplicate comments. +const COMMENT_MARKER = ""; + +// Applied by a maintainer to let a deliberate, non-Decap content edit through +// (fixture repair, bulk migration, …). Applying the label fires a `labeled` +// event, which re-runs this check — and it then passes. +const OVERRIDE_LABEL = "content-guard/override"; + +// The literal marker Decap CMS puts at the start of every editorial-workflow +// PR body it opens. Mirrors the same string in label-non-decap-prs.yml's +// isDecap triad — keep both in sync if Decap ever changes this text. +const DECAP_BODY_MARKER = "Automatically generated by Decap CMS"; + +// Base CMS-managed content directories, derived from theme/admin/config.base.yml's +// base collections' `folder:` values (_posts, _tags, _projects, pages, _e2e) +// plus its `media_folder` (assets/images/uploads). Update BOTH together if +// either changes. +const BASE_CONTENT_DIRS = [ + "_posts/", + "_tags/", + "_projects/", + "pages/", + "_e2e/", + "assets/images/uploads/", +]; + +function normalizeDir(raw) { + const trimmed = String(raw).trim().replace(/^["']|["']$/g, "").trim(); + if (!trimmed) return ""; + return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; +} + +// Extract every site-specific collection folder from a consumer's +// admin/collections.site.yml (the seam the base config injects at +// `__SITE_COLLECTIONS__`). A line-regex is fine here — this is leaf-token +// extraction over a simple list-of-collections YAML, not general parsing. +function seamContentDirs(seamYamlText) { + if (!seamYamlText) return []; + const dirs = []; + const re = /^\s*folder:\s*(.+?)\s*$/gm; + let match; + while ((match = re.exec(seamYamlText)) !== null) { + const dir = normalizeDir(match[1]); + if (dir) dirs.push(dir); + } + return dirs; +} + +function labelNames(labels) { + return (labels || []).map((l) => (typeof l === "string" ? l : (l && l.name) || "")); +} + +// Mirrors the isDecap triad in .github/workflows/label-non-decap-prs.yml: a +// PR is Decap-shaped if its head branch uses the Decap branch convention, its +// body carries Decap's marker, or it already carries a decap-cms/* status +// label. Any one signal is enough — a genuine Decap PR should never be +// mis-flagged even if one signal is missing or spoofed. +function isDecapShaped(pr, decapBranchPrefix) { + if ((pr && pr.head && pr.head.ref || "").startsWith(decapBranchPrefix)) return true; + if ((pr && pr.body || "").startsWith(DECAP_BODY_MARKER)) return true; + return labelNames(pr && pr.labels).some((name) => name.startsWith("decap-cms/")); +} + +const FILE_LIST_CAP = 20; + +function buildCommentBody(contentFiles, adminUrl) { + const shown = contentFiles.slice(0, FILE_LIST_CAP); + const remaining = contentFiles.length - shown.length; + const fileList = + shown.map((f) => `- \`${f}\``).join("\n") + (remaining > 0 ? `\n- _+${remaining} more_` : ""); + + return [ + COMMENT_MARKER, + "", + "### This PR can't merge as-is: it edits CMS-managed content outside Decap", + "", + "This PR touches the following file(s), which this site manages through Decap CMS's editorial workflow — but the PR wasn't created by Decap:", + "", + fileList, + "", + "**Why this is blocked.** Every content change on this site is supposed to be one entry on a `cms//` branch, opened by Decap itself. Editing these paths any other way skips everything Decap's editorial workflow depends on: the entry's draft/review/ready status, its stable per-entry preview alias, publish/schedule semantics (`published` / `publish_date`), and consistent media handling. A change that lands outside that flow can also collide with Decap's own on-repo state (labels, branch naming, in-flight entries), since Decap has no idea the edit happened.", + "", + `**What to do instead.** Make this change at [${adminUrl}](${adminUrl}) — the Decap Workflow tab creates a PR in exactly this shape (\`cms//\` branch, entry body, status labels) automatically.`, + "", + `**Escape hatch.** If this genuinely is deliberate maintenance — repairing a broken fixture, a bulk migration — rather than an ordinary content edit, a maintainer can apply the \`${OVERRIDE_LABEL}\` label to this PR. That fires the \`labeled\` event, which re-runs this check, and it will then pass.`, + ].join("\n"); +} + +/** + * Evaluate whether a PR's content changes conform to the Decap editorial + * workflow. + * + * @param {object} params + * @param {object} params.pr - the pull_request payload object. + * @param {string[]} params.changedFiles - every file path touched by the PR. + * @param {string|null} params.seamYamlText - raw admin/collections.site.yml + * text, or null/empty if the site has no site-specific collections. + * @param {string} params.decapBranchPrefix - e.g. "cms/". + * @param {string[]} [params.extraContentDirs] - extra dirs to treat as content. + * @param {string} params.adminUrl - the site's /admin/ URL, for the comment. + * @returns {{verdict: "pass"|"fail", reason: string, contentFiles: string[], commentBody: string|null}} + */ +function evaluateContentGuard({ + pr, + changedFiles, + seamYamlText, + decapBranchPrefix, + extraContentDirs = [], + adminUrl, +}) { + const contentDirs = [...BASE_CONTENT_DIRS, ...seamContentDirs(seamYamlText), ...extraContentDirs]; + const contentFiles = (changedFiles || []).filter((f) => contentDirs.some((d) => f.startsWith(d))); + + if (contentFiles.length === 0) { + return { verdict: "pass", reason: "no-content-changes", contentFiles, commentBody: null }; + } + if (isDecapShaped(pr, decapBranchPrefix)) { + return { verdict: "pass", reason: "decap-shaped", contentFiles, commentBody: null }; + } + if (labelNames(pr && pr.labels).some((name) => name === OVERRIDE_LABEL)) { + return { verdict: "pass", reason: "override", contentFiles, commentBody: null }; + } + + return { + verdict: "fail", + reason: "non-decap-content-change", + contentFiles, + commentBody: buildCommentBody(contentFiles, adminUrl), + }; +} + +module.exports = { + COMMENT_MARKER, + OVERRIDE_LABEL, + DECAP_BODY_MARKER, + BASE_CONTENT_DIRS, + seamContentDirs, + isDecapShaped, + evaluateContentGuard, +};