diff --git a/.github/workflows/validate-vendor.yml b/.github/workflows/validate-vendor.yml index ac4fa1c..ece497c 100644 --- a/.github/workflows/validate-vendor.yml +++ b/.github/workflows/validate-vendor.yml @@ -2,14 +2,18 @@ name: ✅ Validate Vendor PR on: pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] paths: - "vendors/**" - - "vendors/_schema.json" - "logos/**" - - "scripts/validate-all.mjs" + - "scripts/**" + - "package.json" + - "package-lock.json" + - ".github/workflows/validate-vendor.yml" permissions: contents: read + pull-requests: read jobs: validate: @@ -30,3 +34,8 @@ jobs: - name: Run repository validator run: npm run validate + + - name: Run PR metadata validator + run: npm run validate:pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 43bb898..d0d8367 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -64,7 +64,7 @@ This creates the Managed Collection in Framer with all fields. Only needs to be 1. Create `vendors/{slug}.json` — copy from `vendors/_example.json` (`slug` uses lowercase letters, numbers, and hyphens only; no `logo` field in JSON) 2. Add logo at `logos/{slug}.{ext}` (square 400×400px recommended, max 200 KB — png, jpg, webp) 3. Social keys supported by schema: `x`, `instagram`, `youtube`, `tiktok`, `nostr` -4. Open a PR — CI validates automatically +4. Open a PR — CI validates automatically. Vendor listing PR titles must start with `Add vendor:`, `Update vendor:`, `Remove vendor:`, or `Deactivate vendor:` 5. Merge → sync and deploy run automatically --- @@ -102,6 +102,7 @@ Both trigger the sync on merge. Both are transparent — the change is visible i ```bash npm install -npm run validate # validate all vendor files locally -npm run sync # manual sync (requires FRAMER_PROJECT_URL and FRAMER_API_KEY exported in your shell) +npm run validate # validate all vendor files locally +npm run validate:pr # validates PR title/body in GitHub Actions; skips locally without GITHUB_EVENT_PATH +npm run sync # manual sync (requires FRAMER_PROJECT_URL and FRAMER_API_KEY exported in your shell) ``` diff --git a/VENDOR_GUIDE.md b/VENDOR_GUIDE.md index 51ceb43..7018241 100644 --- a/VENDOR_GUIDE.md +++ b/VENDOR_GUIDE.md @@ -157,6 +157,8 @@ Use this title: `Add vendor: Your Shop Name` +For later changes, use `Update vendor: Your Shop Name`. For removals or deactivations, use `Remove vendor: Your Shop Name` or `Deactivate vendor: Your Shop Name`. + GitHub will show you a short form with pre-filled example fields. Complete these sections: @@ -224,6 +226,8 @@ A listing can be removed if: Removals go through the same PR process — transparent, on the record, visible to everyone. +Use the PR title `Remove vendor: Vendor Name` for hard removals or `Deactivate vendor: Vendor Name` for soft removals. + --- ## 💬 Questions? diff --git a/package.json b/package.json index 3dc1d63..42bde01 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "sync": "node scripts/sync-to-framer.mjs", - "validate": "node scripts/validate-all.mjs" + "validate": "node scripts/validate-all.mjs", + "validate:pr": "node scripts/validate-pr.mjs" }, "dependencies": { "framer-api": "0.1.1" diff --git a/scripts/validate-pr.mjs b/scripts/validate-pr.mjs new file mode 100644 index 0000000..5330722 --- /dev/null +++ b/scripts/validate-pr.mjs @@ -0,0 +1,128 @@ +/** + * validate-pr.mjs + * + * Validates PR title/body hygiene using the GitHub pull_request event payload. + * Run in CI with: npm run validate:pr + */ + +import fs from "node:fs" + +const eventPath = process.env.GITHUB_EVENT_PATH + +if (!eventPath) { + console.log("Skipping PR metadata validation: GITHUB_EVENT_PATH is not set") + process.exit(0) +} + +let payload +try { + payload = JSON.parse(fs.readFileSync(eventPath, "utf-8").replace(/^\uFEFF/, "")) +} catch (err) { + console.error(`Failed to read GitHub event payload: ${err.message}`) + process.exit(1) +} + +const pr = payload.pull_request +if (!pr) { + console.log("Skipping PR metadata validation: event is not a pull_request") + process.exit(0) +} + +const title = pr.title.trim() +const body = pr.body || "" +const errors = [] +const titleMatch = title.match(/^(Add vendor|Update vendor|Remove vendor|Deactivate vendor): .+/) +const prType = titleMatch?.[1] +const changedFiles = await getChangedFiles() +const touchesVendorListing = changedFiles?.some((file) => file.startsWith("vendors/") || file.startsWith("logos/")) + +if (changedFiles && !touchesVendorListing && !titleMatch) { + console.log("Skipping PR metadata validation: PR does not touch vendor listings") + process.exit(0) +} + +if (!titleMatch) { + errors.push("PR title must start with one of: 'Add vendor:', 'Update vendor:', 'Remove vendor:', or 'Deactivate vendor:'") +} + +const placeholders = [ + "Your shop name", + "https://yourshop.com", + "Your X, Discord, Instagram, TikTok, Nostr, or other public link", +] + +for (const placeholder of placeholders) { + if (body.includes(placeholder)) { + errors.push(`Replace PR template placeholder: ${placeholder}`) + } +} + +if (!/\|\s*\*\*Shop name\*\*\s*\|\s*\S/.test(body)) { + errors.push("PR body must include a completed Shop name row") +} + +if (!/\|\s*\*\*Website\*\*\s*\|\s*https:\/\/\S+/.test(body)) { + errors.push("PR body must include a completed HTTPS Website row") +} + +if (!/\|\s*\*\*Where to find you in the community\*\*\s*\|\s*\S/.test(body)) { + errors.push("PR body must include a completed community contact row") +} + +const confirmationRows = [ + "I sell genuine Bitaxe hardware", + "My shop is live and active", + "I am reachable in the community", + "I use the correct name `Bitaxe` on my Bitaxe product pages", + "My Bitaxe product pages use real product photos", + "My Bitaxe product pages link to `https://bitaxe.org`", + "I do not sell Bitaxe-derived products in ways that violate Bitaxe open-source license terms", + "I am not aware of open fraud or counterfeit reports about my shop", + "I understand listings can be removed if confirmed issues arise", +] + +if (prType === "Add vendor" || prType === "Update vendor") { + for (const label of confirmationRows) { + const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const rowPattern = new RegExp(`\\|\\s*${escapedLabel}\\s*\\|\\s*(Yes|No)\\s*\\|`, "i") + if (!rowPattern.test(body)) { + errors.push(`Vendor confirmation must be answered Yes/No: ${label}`) + } + } +} + +if (errors.length > 0) { + console.error("PR metadata validation failed:") + for (const error of errors) console.error(`- ${error}`) + process.exit(1) +} + +console.log("PR metadata is valid") + +async function getChangedFiles() { + const filesUrl = pr._links?.self?.href ? `${pr._links.self.href}/files` : null + const token = process.env.GITHUB_TOKEN + + if (!filesUrl || !token) return null + + try { + const response = await fetch(filesUrl, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + + if (!response.ok) { + console.warn(`Could not fetch PR files (${response.status}); validating title/body only`) + return null + } + + const files = await response.json() + return files.map((file) => file.filename) + } catch (err) { + console.warn(`Could not fetch PR files; validating title/body only: ${err.message}`) + return null + } +}