Skip to content
Closed
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
27 changes: 27 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Validate SPP Data

on:
push:

jobs:
validate:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: |
cd scripts
bun install

- name: Run validation
run: |
cd scripts
bun run validate
8 changes: 8 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"semi": false,
"tabWidth": 2,
"printWidth": 80,
"useTabs": false,
"singleQuote": true,
"trailingComma": "es5"
}
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode"]
}
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ The goal of this repo is to provide all relevant data for ENS DAO Service Provid

## Structure

- Each SPP season has its own folder in the root of the repo, e.g. `spp-2`
- Each SPP season has its own folder in the root of the repo, e.g. `spp-2`.
- Each provider has its own folder within the SPP season with the following structure:
- `proposal.md` - the initial proposal for the season that got approved by the ENS DAO, including a bunch of stuff in the frontmatter, e.g. `name`, `description`, `logo`, `website`, `twitter`, `github`
- `assets/` - any relevant files, like images, for the proposal/updates, e.g. `logo.svg`
- `updates/` - the quarterly updates for the provider
- `1.md` - the first update for the provider, e.g. `Quarterly Report Q1 2025`
- `2.md` - the second update for the provider, e.g. `Quarterly Report Q2 2025`, etc.
- `proposal.md` - the initial proposal for the season that got approved by the ENS DAO. This file must include basic information about the provider in the frontmatter including `name`, `description`, `logo`, `website`, `twitter`, `github`.
- `assets/` - any relevant files, like images, for the proposal/updates, e.g. `logo.svg`.
- `updates/` - the quarterly updates for the provider.
- `1.md` - the first update for the provider in the given season.
- ...

## Guidelines

ENS DAO MetaGov stewards are admins of this repo, and responsible for merging PRs from service providers. They should only merge PRs once the formatting is correct.
ENS DAO MetaGov stewards are admins of this repo, and responsible for merging PRs from service providers. They should only merge PRs once the formatting is correct, which is partially validated by the CI.
34 changes: 34 additions & 0 deletions scripts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules

# output
out
dist
*.tgz

# code coverage
coverage
*.lcov

# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

# caches
.eslintcache
.cache
*.tsbuildinfo

# IntelliJ based IDEs
.idea

# Finder (MacOS) folder config
.DS_Store
58 changes: 58 additions & 0 deletions scripts/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions scripts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"private": true,
"type": "module",
"scripts": {
"validate": "bun run src/index.ts"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"gray-matter": "^4.0.3",
"marked": "^16.2.0",
"zod": "^4.1.3"
}
}
27 changes: 27 additions & 0 deletions scripts/src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import path from 'node:path'

type GitHubErrorOptions = {
filename: string
title: string
message: string
}

export class GitHubError extends Error {
public readonly filename: string
public readonly title: string
public readonly annotation: string

constructor({ filename, title, message }: GitHubErrorOptions) {
// Use the message as the regular error message
super(message)

// Convert the filename to a relative path
const rootPath = path.join(import.meta.dirname, '..', '..')
const relativeFilename = path.relative(rootPath, filename)

// Store the GitHub annotation separately
this.filename = relativeFilename
this.title = title
this.annotation = `::error file=${relativeFilename},line=1,title=${title}::${message}`
}
}
100 changes: 100 additions & 0 deletions scripts/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { readdir } from 'node:fs/promises'
import matter from 'gray-matter'
import path from 'node:path'

import { GitHubError } from './error'
import { ProposalFrontmatter } from './schema'
import { validateProposalHeadings, validateUpdateHeadings } from './validate'

const rootPath = path.join(import.meta.dirname, '..', '..')
const root = await readdir(rootPath)
const sppSeasons = root.filter((entry) => entry.startsWith('spp-'))

try {
await validate()
} catch (error) {
if (error instanceof GitHubError) {
console.log(error.annotation)
process.exit(1)
}

throw error
}

async function validate() {
for (const sppSeason of sppSeasons) {
const seasonPath = path.join(rootPath, sppSeason)
const providers = await readdir(seasonPath)

for (const provider of providers) {
const providerPath = path.join(seasonPath, provider)

// Read the proposal.md file
const proposalPath = path.join(providerPath, 'proposal.md')
const { data: proposalFrontmatter, content: proposalMarkdown } = matter(
await Bun.file(proposalPath).text()
)

// Validate the frontmatter
const { data: validatedFrontmatter, error: frontmatterError } =
ProposalFrontmatter.safeParse(proposalFrontmatter)

if (frontmatterError) {
throw new GitHubError({
filename: proposalPath,
title: 'Invalid frontmatter',
message: `The frontmatter is invalid: ${frontmatterError.issues
.map((issue) => issue.message)
.join(', ')}`,
})
}

// Check if the logo (as a relative path) exists
const logoPath = path.join(providerPath, validatedFrontmatter.logo)
const logoExists = await Bun.file(logoPath).exists()
if (!logoExists) {
throw new GitHubError({
filename: proposalPath,
title: 'Logo does not exist',
message: `\`${validatedFrontmatter.logo}\` does not exist`,
})
}

validateProposalHeadings(proposalMarkdown, proposalPath)

const updatesPath = path.join(providerPath, 'updates')
const updates = await readdir(updatesPath)
const updateFiles = updates.filter((file) => file.endsWith('.md'))

// Make sure there's nothing else besides {number}.md files
if (updateFiles.length !== updates.length) {
throw new GitHubError({
filename: providerPath,
title: 'Invalid update files',
message: `There should only be numbered .md files in the updates folder`,
})
}

// Check that updates are numbered sequentially
for (let i = 0; i < updateFiles.length; i++) {
const update = updateFiles[i]!
const updateNumber = parseInt(update.split('.')[0]!)
if (updateNumber !== i + 1) {
throw new GitHubError({
filename: updatesPath,
title: 'Updates are not numbered sequentially',
message: `The update files are not numbered sequentially`,
})
}
}

for (const updateFile of updateFiles) {
const updatePath = path.join(providerPath, 'updates', updateFile)
const updateContent = await Bun.file(updatePath).text()
const { content: updateMarkdown } = matter(updateContent)

validateUpdateHeadings(updateMarkdown, updatePath)
}
}
}
}
12 changes: 12 additions & 0 deletions scripts/src/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { z } from 'zod'

export const ProposalFrontmatter = z.object({
name: z.string('Name is required'),
description: z.string('Description is required'),
logo: z
.string('Logo is required as a relative path')
.refine((str) => str.startsWith('./')),
website: z.url('Website is required'),
twitter: z.url('Twitter (full URL, not just username) is required'),
github: z.url('GitHub (full URL, not just username) is required'),
})
79 changes: 79 additions & 0 deletions scripts/src/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { marked, type Tokens } from 'marked'

import { GitHubError } from './error'

const requiredProposalH2s = [
'Applicant Information',
'Eligibility Confirmation',
'Open Source Commitment',
'Scope of Work & Budget',
'Past Achievements & Additional Information',
'Video Introduction',
'Conflict Of Interest Statment',
]

export function validateProposalHeadings(content: string, filename: string) {
const { h1s, h2s } = extractHeadings(content)

if (h1s.length !== 1) {
throw new GitHubError({
filename,
title: 'Only one H1 is allowed',
message: 'Please add only one H1 to the proposal.md file.',
})
}

const missingH2s = requiredProposalH2s.filter(
(h2) => !h2s.some((h) => h.text.includes(h2))
)

if (missingH2s.length > 0) {
throw new GitHubError({
filename,
title: 'Missing required headings',
message: `This proposal is missing the following headings: ${missingH2s.join(
', '
)}`,
})
}
}

const requiredUpdateH2s = ['Summary', 'KPIs']

export function validateUpdateHeadings(content: string, filename: string) {
const { h1s, h2s } = extractHeadings(content)

if (h1s.length !== 1) {
throw new GitHubError({
filename,
title: 'Only one H1 is allowed',
message: 'Please add only one H1 to the update.md file.',
})
}

const missingH2s = requiredUpdateH2s.filter(
(h2) => !h2s.some((h) => h.text.includes(h2))
)

if (missingH2s.length > 0) {
throw new GitHubError({
filename,
title: 'Missing required headings',
message: `This update is missing the following headings: ${missingH2s.join(
', '
)}`,
})
}
}

function extractHeadings(content: string) {
const tokens = marked.lexer(content)
const headingTokens = tokens.filter(
(token) => token.type === 'heading'
) as Tokens.Heading[]

const h1s = headingTokens.filter((token) => token.depth === 1)
const h2s = headingTokens.filter((token) => token.depth === 2)

return { h1s, h2s }
}
Loading