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
13 changes: 11 additions & 2 deletions .github/workflows/validate-vendor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
7 changes: 4 additions & 3 deletions MAINTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down Expand Up @@ -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)
```
4 changes: 4 additions & 0 deletions VENDOR_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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?
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
128 changes: 128 additions & 0 deletions scripts/validate-pr.mjs
Original file line number Diff line number Diff line change
@@ -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
}
}