Skip to content

feat: add Volcano Eruption Simulator #942

feat: add Volcano Eruption Simulator

feat: add Volcano Eruption Simulator #942

Workflow file for this run

name: PR Validation
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: read
issues: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Validate project submission
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const prNum = context.payload.pull_request.number;
const prHead = context.payload.pull_request.head.sha;
const prAuthor = context.payload.pull_request.user.login;
// ── Collect all changed files ─────────────────────────────────
const { data: files } = await github.rest.pulls.listFiles({
owner, repo, pull_number: prNum,
});
const normalized = files.map(f => ({
...f,
fl: f.filename.toLowerCase(),
st: (f.status || 'added').toLowerCase(),
}));
const errors = [];
// ── SECTION 1: File scope ─────────────────────────────────────
const outsideProjects = files.some(f => !f.filename.startsWith('Projects/'));
const rootJsonTouched = files.some(f => f.filename === 'project.json');
if (rootJsonTouched || outsideProjects) {
errors.push(
'All changes must be inside a new project folder under `Projects/`. ' +
'Do not modify root-level files or any file outside your project folder.'
);
}
const projectDirs = new Set();
for (const f of normalized) {
if (f.filename.startsWith('Projects/')) {
const parts = f.filename.split('/');
if (parts.length >= 2) projectDirs.add(parts.slice(0, 2).join('/'));
}
}
if (projectDirs.size === 0) {
errors.push(
'No files were found under `Projects/`. ' +
'Place your project inside a new folder in `Projects/`.'
);
} else if (projectDirs.size > 1) {
errors.push(
`This PR touches ${projectDirs.size} different project folders. ` +
'A single PR must contain exactly one new project.'
);
}
if (files.length > 6) {
errors.push(
`This PR contains ${files.length} files. ` +
'The maximum for a single project submission is 6 files. ' +
'Remove any files that are not part of the project.'
);
}
// If we cannot identify a single project dir, stop and report now
if (projectDirs.size !== 1) {
await postResult(errors, null, prAuthor);
return;
}
const [projectDir] = [...projectDirs];
const folderName = projectDir.split('/')[1];
// ── SECTION 2: Folder name ────────────────────────────────────
function isTitleCase(name) {
if (/[-_]/.test(name)) return false;
return name.split(' ').every(w => /^[A-Z0-9][A-Za-z0-9]*$/.test(w));
}
if (!isTitleCase(folderName)) {
errors.push(
`The folder name "${folderName}" does not follow the required format. ` +
'Folder names must be Title Case with real spaces, e.g. "To Do Web App". ' +
'Hyphens and underscores are not allowed.'
);
}
// ── SECTION 3: Required files ─────────────────────────────────
const dirLower = projectDir.toLowerCase();
const hasReadme = normalized.some(f => f.fl === `${dirLower}/readme.md`);
const hasProjectJson = normalized.some(f => f.fl === `${dirLower}/project.json`);
const hasModified = normalized.some(
f => f.fl.startsWith(`${dirLower}/`) && f.st !== 'added'
);
if (!hasReadme) {
errors.push('A `README.md` file is required inside your project folder.');
}
if (!hasProjectJson) {
errors.push('A `project.json` metadata file is required inside your project folder.');
}
if (hasModified) {
errors.push(
'This PR modifies one or more existing files. ' +
'A project submission must only add new files — no edits to existing files are permitted.'
);
}
// ── SECTION 4: project.json content validation ────────────────
if (hasProjectJson) {
let meta = null;
try {
const { data: fileData } = await github.rest.repos.getContent({
owner,
repo,
path: `${projectDir}/project.json`,
ref: prHead,
});
const raw = Buffer.from(fileData.content, 'base64').toString('utf8');
meta = JSON.parse(raw);
} catch (e) {
errors.push(
`\`project.json\` could not be read or parsed: ${e.message}. ` +
'Ensure the file contains valid JSON.'
);
}
if (meta) {
const REQUIRED = ['title', 'description', 'author', 'tags', 'entry'];
for (const field of REQUIRED) {
if (!meta[field]) {
errors.push(`\`project.json\` is missing the required field \`${field}\`.`);
}
}
if (meta.title && meta.title !== folderName) {
errors.push(
`\`project.json\` title "${meta.title}" must exactly match the folder name "${folderName}".`
);
}
if (meta.author && typeof meta.author === 'object') {
if (!meta.author.name) {
errors.push('`project.json` author object is missing `name`.');
}
if (!meta.author.github) {
errors.push('`project.json` author object is missing `github` (your GitHub username).');
}
}
if (meta.tags !== undefined) {
if (!Array.isArray(meta.tags)) {
errors.push('`project.json` field `tags` must be a JSON array.');
} else if (meta.tags.length < 1 || meta.tags.length > 6) {
errors.push(
`\`project.json\` \`tags\` must have between 1 and 6 entries (found ${meta.tags.length}).`
);
}
}
if (meta.entry) {
const entryLower = `${dirLower}/${meta.entry.toLowerCase()}`;
const entryInPR = normalized.some(f => f.fl === entryLower);
if (!entryInPR) {
errors.push(
`\`project.json\` lists "${meta.entry}" as the entry point, ` +
'but that file was not found in this PR. Make sure the file is included.'
);
}
}
}
}
// ── Post result ───────────────────────────────────────────────
await postResult(errors, projectDir, prAuthor);
// ── Helpers ───────────────────────────────────────────────────
async function postResult(errs, dir, author) {
if (errs.length === 0) {
// All checks passed
await github.rest.issues.addLabels({
owner, repo, issue_number: prNum,
labels: ['nsoc2026'],
}).catch(() => {});
await github.rest.issues.createComment({
owner, repo, issue_number: prNum,
body: [
'**PR Validation — All Checks Passed**',
'',
`@${author}, your project submission has passed all validation checks and is ready for review.`,
'',
'A thumbnail will be auto-generated for your project when this PR is merged.',
'',
'Thank you for contributing to OpenStudio.',
].join('\n'),
});
} else {
const lines = [
'**PR Validation — Checks Failed**',
'',
'The following issues must be resolved before this PR can be merged.',
'Correct them in your branch and push a new commit — this check will re-run automatically.',
'',
];
for (const e of errs) lines.push(`- ${e}`);
lines.push('', 'Refer to [CONTRIBUTING.md](../../blob/main/CONTRIBUTING.md) for the full submission requirements.');
await github.rest.issues.createComment({
owner, repo, issue_number: prNum,
body: lines.join('\n'),
});
}
}