feat(cli): add explicit upgrade integrations #7815
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
| # Copyright (c) Meta Platforms, Inc. and affiliates. | |
| # Code owner gate: blocks merge unless a code owner has approved | |
| # Code owners themselves can merge freely (auto-pass) | |
| # | |
| # Design owners (.github/DESIGNOWNERS) can merge sandbox, storybook, | |
| # and CLI template changes without code owner approval. Everyone else | |
| # needs it. | |
| # | |
| # Uses the Checks API to write a single "Code Owner Gate" status per | |
| # commit SHA, so re-runs from pull_request_review events UPDATE the | |
| # existing check instead of creating a competing one. | |
| name: Code Owner Gate | |
| on: | |
| pull_request: | |
| branches: ["main"] | |
| types: [opened, synchronize, reopened] | |
| pull_request_review: | |
| types: [submitted] | |
| permissions: {} | |
| jobs: | |
| codeowner-gate: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| checks: write | |
| pull-requests: read | |
| contents: read | |
| steps: | |
| - name: Checkout CODEOWNERS and DESIGNOWNERS | |
| uses: actions/checkout@v6 | |
| with: | |
| sparse-checkout: | | |
| .github/CODEOWNERS | |
| .github/DESIGNOWNERS | |
| sparse-checkout-cone-mode: false | |
| - name: Check code owner status | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| // --- Configuration --- | |
| // Read code owners from .github/CODEOWNERS | |
| let CODE_OWNERS = []; | |
| try { | |
| const codeownersContent = fs.readFileSync('.github/CODEOWNERS', 'utf8'); | |
| CODE_OWNERS = [...new Set( | |
| codeownersContent | |
| .split('\n') | |
| .map(line => line.replace(/#.*$/, '').trim()) // strip comments | |
| .filter(line => line.length > 0) | |
| .flatMap(line => { | |
| // Each line is: <pattern> <owners...> | |
| // Owners start with @, strip the @ prefix | |
| const parts = line.split(/\s+/); | |
| return parts.slice(1).map(owner => owner.replace(/^@/, '')); | |
| }) | |
| )]; | |
| } catch (e) { | |
| console.log('No CODEOWNERS file found, falling back to empty list'); | |
| } | |
| console.log(`Code owners: ${CODE_OWNERS.join(', ') || '(none)'}`); | |
| // Read design owners from file | |
| let DESIGNOWNERS = []; | |
| try { | |
| const content = fs.readFileSync('.github/DESIGNOWNERS', 'utf8'); | |
| DESIGNOWNERS = content | |
| .split('\n') | |
| .map(line => line.replace(/#.*$/, '').trim()) // strip comments | |
| .filter(line => line.length > 0); | |
| } catch (e) { | |
| console.log('No DESIGNOWNERS file found, skipping design owner check'); | |
| } | |
| console.log(`Design owners: ${DESIGNOWNERS.join(', ') || '(none)'}`); | |
| // Paths that design owners can merge without code owner review | |
| const DESIGN_PATHS = [ | |
| 'apps/sandbox/', | |
| 'apps/storybook/', | |
| 'packages/cli/templates/', | |
| ]; | |
| // File patterns that design owners can also merge (glob-style) | |
| // .doc.mjs files are doc metadata, not component logic | |
| const DESIGN_FILE_PATTERNS = [ | |
| /\.doc\.mjs$/, | |
| ]; | |
| // --- Resolve PR context --- | |
| const pr = context.payload.pull_request; | |
| const prNumber = pr.number; | |
| const prAuthor = pr.user.login; | |
| const headSha = pr.head.sha; | |
| console.log(`PR #${prNumber} by @${prAuthor} (${headSha.slice(0, 7)})`); | |
| // Fetch changed files (paginated — PRs with 100+ files) | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| per_page: 100, | |
| }); | |
| // Collect all affected paths — includes previous_filename | |
| // for renames so a rename FROM core TO sandbox is caught. | |
| const allPaths = files.flatMap(f => | |
| f.previous_filename ? [f.filename, f.previous_filename] : [f.filename] | |
| ); | |
| const changedFiles = files.map(f => f.filename); | |
| const isDesignOwned = (p) => | |
| DESIGN_PATHS.some(prefix => p.startsWith(prefix)) || | |
| DESIGN_FILE_PATTERNS.some(re => re.test(p)); | |
| const onlyDesignPaths = allPaths.length > 0 && allPaths.every(isDesignOwned); | |
| // --- Determine result --- | |
| let conclusion = 'failure'; | |
| let summary = ''; | |
| if (CODE_OWNERS.includes(prAuthor)) { | |
| conclusion = 'success'; | |
| summary = `@${prAuthor} is a code owner — approved automatically.`; | |
| } else if (onlyDesignPaths && DESIGNOWNERS.includes(prAuthor)) { | |
| conclusion = 'success'; | |
| summary = `@${prAuthor} is a design owner and only design-owned paths changed — approved automatically.\nFiles: ${changedFiles.join(', ')}`; | |
| } else { | |
| // Check for code owner approval | |
| const { data: reviews } = await github.rest.pulls.listReviews({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| const latestReviews = new Map(); | |
| for (const review of reviews) { | |
| latestReviews.set(review.user.login, review.state); | |
| } | |
| const approvedBy = [...latestReviews.entries()] | |
| .filter(([user, state]) => state === 'APPROVED' && CODE_OWNERS.includes(user)) | |
| .map(([user]) => user); | |
| if (approvedBy.length > 0) { | |
| conclusion = 'success'; | |
| summary = `Approved by code owner(s): ${approvedBy.map(u => '@' + u).join(', ')}`; | |
| } else { | |
| // Tailor the message based on context | |
| if (onlyDesignPaths && !DESIGNOWNERS.includes(prAuthor)) { | |
| summary = `@${prAuthor} is not a design owner. These paths require code owner approval (${CODE_OWNERS.map(u => '@' + u).join(', ')}) or being listed in .github/DESIGNOWNERS.`; | |
| } else { | |
| summary = `Requires approval from a code owner (${CODE_OWNERS.map(u => '@' + u).join(', ')}).`; | |
| } | |
| } | |
| } | |
| console.log(`${conclusion === 'success' ? '✅' : '❌'} ${summary}`); | |
| // --- Write check run (upsert) --- | |
| const checkName = 'Code Owner Gate'; | |
| const { data: existing } = await github.rest.checks.listForRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: headSha, | |
| check_name: checkName, | |
| }); | |
| const params = { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: checkName, | |
| head_sha: headSha, | |
| status: 'completed', | |
| conclusion, | |
| output: { | |
| title: conclusion === 'success' ? 'Approved' : 'Waiting for code owner approval', | |
| summary, | |
| }, | |
| }; | |
| if (existing.total_count > 0) { | |
| await github.rest.checks.update({ | |
| ...params, | |
| check_run_id: existing.check_runs[0].id, | |
| }); | |
| console.log(`Updated check run ${existing.check_runs[0].id}`); | |
| } else { | |
| const { data: created } = await github.rest.checks.create(params); | |
| console.log(`Created check run ${created.id}`); | |
| } |