feat: add Volcano Eruption Simulator #942
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'), | |
| }); | |
| } | |
| } |