[Project] Community Infrastructure Management Portal #1163 #958
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: write | |
| issues: write | |
| jobs: | |
| validate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Validate project submission | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.MERGE_PAT }} | |
| 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 (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) { | |
| // ── PASS: add labels, comment, then merge ───────────────── | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: prNum, | |
| labels: ['level3', "NSoC'26 Accepted", "NSoC'26", 'nsoc26'], | |
| }).catch(e => core.warning(`Label error: ${e.message}`)); | |
| 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!`, | |
| '', | |
| 'Your PR has been approved and will now be merged automatically.', | |
| 'A thumbnail will be auto-generated for your project once the merge is complete.', | |
| '', | |
| 'Thank you for contributing to OpenStudio 🎉', | |
| ].join('\n'), | |
| }); | |
| // Auto-merge the PR | |
| await github.rest.pulls.merge({ | |
| owner, repo, | |
| pull_number: prNum, | |
| merge_method: 'squash', | |
| commit_title: `Add project: ${dir ? dir.split('/')[1] : 'submission'} (#${prNum})`, | |
| commit_message: `Submitted by @${author}`, | |
| }).catch(e => core.warning(`Merge error: ${e.message}`)); | |
| } else { | |
| // ── FAIL: comment only, no labels, no merge ─────────────── | |
| const lines = [ | |
| '**PR Validation — Checks Failed ❌**', | |
| '', | |
| 'The following issues must be resolved before this PR can be merged.', | |
| 'Fix 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'), | |
| }); | |
| } | |
| } |