Skip to content

Commit

Permalink
Merge pull request #55 from sergeyk-symbiotic/main
Browse files Browse the repository at this point in the history
Add metadata validation and extraction workflows
  • Loading branch information
sergey-symbiotic authored Jan 14, 2025
2 parents 0cb0c8d + d6b366c commit a73ce09
Show file tree
Hide file tree
Showing 9 changed files with 459 additions and 0 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/full-info.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Get full info

on:
push:
branches:
- main

jobs:
generate:
runs-on: self-hosted

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

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Install dependencies
run: npm install tsx

- name: Generate
run: npx tsx .github/workflows/scripts/extract-metadata.ts

- name: Release latest full info
uses: softprops/action-gh-release@v1
with:
name: latest
tag_name: latest
files: full-info.json
fail_on_unmatched_files: true
81 changes: 81 additions & 0 deletions .github/workflows/scripts/extract-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import fs from 'fs/promises';
import path from 'path';
import { pathToFileURL } from 'url';

type Info = Partial<{
name: string;
description: string;
tags: string[];
cmcId: string;
links: [
{
type: string;
name: string;
url: string;
},
{
type: string;
name: string;
url: string;
},
{
type: string;
name: string;
url: string;
}
];
}>;

type Entity = {
info: Info;
logo: string;
};

enum DIRECTORIES {
VAULTS = 'vaults',
TOKENS = 'tokens',
NETWORKS = 'networks',
OPERATORS = 'operators',
POINTS = 'points',
}

type Template = Record<DIRECTORIES, Record<string, Entity>>;

async function grabEntitiesInfo(globalDirs: DIRECTORIES[]) {
const result = Object.values(DIRECTORIES).reduce<Template>((acc, curr) => {
acc[curr] = {};
return acc;
}, {} as Template);

for (const dir of globalDirs) {
try {
const subdirs = await fs.readdir(dir);
for (const subdir of subdirs) {
const entityPath = path.join(dir, subdir);
try {
const infoPath = path.join(entityPath, 'info.json');
const infoUrl = pathToFileURL(infoPath).href;

const module = await import(infoUrl);
const info: Info = module.default;
result[dir as DIRECTORIES][subdir] = {
info,
logo:
'https://raw.githubusercontent.com/symbioticfi/metadata-holesky/main/' +
entityPath +
'/logo.png',
};
} catch (error) {
console.error('Error processing entity in ' + entityPath, error);
}
}
} catch (error) {
console.error('Error reading directory ' + dir, error);
}
}

const filePath = path.join(process.cwd(), 'full-info.json');
await fs.writeFile(filePath, JSON.stringify(result, null, '\t'), 'utf8');
}

grabEntitiesInfo(Object.values(DIRECTORIES));
49 changes: 49 additions & 0 deletions .github/workflows/scripts/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as core from '@actions/core';
import * as github from '@actions/github';

const token = process.env.GITHUB_TOKEN;

if (!token) {
core.setFailed('GITHUB_TOKEN env variable is required');
process.exit(1);
}

const octokit = github.getOctokit(token);

export type ReviewComment = {
path: string;
body: string;
line?: number;
position?: number;
}

export type Review = {
body?: string;
comments?: ReviewComment[];
}

export const addComment = async (body: string) => {
const { owner, repo, number } = github.context.issue;

await octokit.rest.issues.createComment({ owner, repo, issue_number: number, body });
};

export const addReview = async (review: Review) => {
const { owner, repo, number } = github.context.issue;

await octokit.rest.pulls.createReview({
owner,
repo,
pull_number: number,
event: 'COMMENT',
...review,
});
};

export const run = async (command: () => Promise<void>) => {
try {
await command();
} catch (error) {
core.setFailed(error.message);
}
};
33 changes: 33 additions & 0 deletions .github/workflows/scripts/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const contributionGuidelines = `Please, follow the [contribution guidelines](https://github.com/symbioticfi/metadata-holesky/blob/main/README.md).`;

type JSONSchemaError = {
line: number;
message: string;
};

export const notAllowedChanges = (files: string[]) =>
`We detected changes in the pull request that are not allowed. ${contributionGuidelines}
**Not allowed files:**
${files.map((file) => `- ${file}`).join('\n')}
`;

export const onlyOneEntityPerPr = (dirs: string[]) =>
`It is not allowed to change more than one entity in a single pull request. ${contributionGuidelines}
**Entities:**
${dirs.map((file) => `- ${file}`).join('\n')}
`;

export const noInfoJson = (entityDir: string, files: string[]) =>
`The entity folder should have \`info.json\` file. ${contributionGuidelines}`;

export const invalidInfoJson = (path: string, erros: JSONSchemaError[]) =>
`The \`info.json\` file is invalid. ${contributionGuidelines}`;

export const invalidLogo = (path: string, errors: string[]) =>
`The logo image is invalid. ${contributionGuidelines}
**Unmet requirements:**
${errors.map((error) => `- ${error}`).join('\n')}
`;
53 changes: 53 additions & 0 deletions .github/workflows/scripts/schemas/info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"type": "object",
"properties": {
"name": {
"type": "string"
},

"tags": {
"type": "array",
"items": {
"type": "string"
}
},

"links": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["website", "explorer", "docs", "example"]
},

"name": {
"type": "string"
},

"url": {
"type": "string",
"format": "uri"
}
},

"required": ["type", "name", "url"]
}
},

"cmcId": {
"type": "string"
},

"permitName": {
"type": "string"
},

"permitVersion": {
"type": "string"
}
},

"required": ["name", "tags", "links"]
}
84 changes: 84 additions & 0 deletions .github/workflows/scripts/validate-fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import path from 'path';
import fs from 'fs';
import * as core from '@actions/core';
import * as github from './github';
import * as messages from './messages';

const addressRegex = /^0x[a-fA-F0-9]{40}$/;
const allowedTypes = ['vaults', 'operators', 'networks', 'tokens'];
const allowedFiles = ['info.json', 'logo.png'];
const changedFiles = process.argv.slice(2);

github.run(async () => {
const notAllowed = new Set<string>();
const entityDirs = new Set<string>();

for (const filePath of changedFiles) {
const dir = path.dirname(filePath);
const [type, address, fileName] = filePath.split(path.sep);
const isValid =
allowedTypes.includes(type) && addressRegex.test(address) && allowedFiles.includes(fileName);

if (isValid) {
entityDirs.add(dir);
} else {
notAllowed.add(filePath);
}
}

/**
* Validate that there are only allowed changes
*/
if (notAllowed.size) {
await github.addComment(messages.notAllowedChanges([...notAllowed]));

throw new Error(
`The pull request includes changes outside the allowed directories:\n ${[...notAllowed].join(
', '
)}`
);
}

/**
* Validate that only one entity is changed per pull request
*/
if (entityDirs.size > 1) {
await github.addComment(messages.onlyOneEntityPerPr([...entityDirs]));

throw new Error('Several entities are changed in one pull request');
}

const [entityDir] = entityDirs;
const existingFiles = await fs.promises.readdir(entityDir);

const [metadataPath, logoPath] = allowedFiles.map((name) => {
return existingFiles.includes(name) ? path.join(entityDir, name) : undefined;
});

const [isMetadataChanged, isLogoChanged] = allowedFiles.map((name) => {
return changedFiles.some((file) => path.basename(file) === name);
});

/**
* Validate that metadata present in the entity folder.
*/
if (!metadataPath) {
await github.addComment(messages.noInfoJson(entityDir, existingFiles));

throw new Error('`info.json` is not found in the entity folder');
}

/**
* Send metadata to the next validation step only if the file was changed and exists.
*/
if (isMetadataChanged && metadataPath) {
core.setOutput('metadata', metadataPath);
}

/**
* Send logo to the next validation step only if the file was changed and exists.
*/
if (isLogoChanged && logoPath) {
core.setOutput('logo', logoPath);
}
});
37 changes: 37 additions & 0 deletions .github/workflows/scripts/validate-logo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import sharp from 'sharp';
import * as fs from 'fs/promises';

import * as github from './github';
import * as messages from './messages';

const [logoPath] = process.argv.slice(2);

github.run(async () => {
const image = sharp(logoPath);

const errors: string[] = [];
const { size } = await fs.stat(logoPath);
const { hasAlpha, width = 0, height = 0, format } = await image.metadata();

if (format !== 'png') {
errors.push('The image format should be PNG');
}

if (size > 1024 * 1024) {
errors.push('The image is too large. The maximum size is 1MB');
}

if (!hasAlpha) {
errors.push('The image background should be transparent');
}

if (width != 256 || height != 256) {
errors.push('The image size must be 256x256 pixels');
}

if (errors.length) {
await github.addComment(messages.invalidLogo(logoPath, errors));

throw new Error('The logo is invalid');
}
});
Loading

0 comments on commit a73ce09

Please sign in to comment.