diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000000..f4ad4dc423 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,114 @@ +# GitHub Actions CI/CD Configuration + +This directory contains GitHub Actions workflows for continuous integration and deployment. + +## PR Comment Commands + +### `/run-skipped-ci` - Run Full CI Suite + +When you open a PR, CI automatically runs a subset of tests for faster feedback (latest Ruby/Node versions only). To run the **complete CI suite** including all dependency combinations, add a comment to your PR: + +``` +/run-skipped-ci +``` + +This command will trigger: + +- ✅ Main test suite with both latest and minimum supported versions +- ✅ All example app generator tests +- ✅ React on Rails Pro integration tests +- ✅ React on Rails Pro package tests + +The bot will: + +1. React with a 🚀 to your comment +2. Post a confirmation message with links to the triggered workflows +3. Start all CI jobs on your PR branch + +### Why This Exists + +By default, PRs run a subset of CI jobs to provide fast feedback: + +- Only latest dependency versions (Ruby 3.4, Node 22) +- Skips example generator tests +- Skips some Pro package tests + +This is intentional to keep PR feedback loops fast. However, before merging, you should verify compatibility across all supported versions. The `/run-skipped-ci` command makes this easy without waiting for the PR to be merged to master. + +### Security & Access Control + +**Only repository collaborators with write access can trigger full CI runs.** This prevents: + +- Resource abuse from external contributors +- Unauthorized access to Pro package tests +- Potential DoS attacks via repeated CI runs + +If an unauthorized user attempts to use `/run-skipped-ci`, they'll receive a message explaining the restriction. + +### Concurrency Protection + +Multiple `/run-skipped-ci` comments on the same PR will cancel in-progress runs to prevent resource waste and duplicate results. + +## Testing Comment-Triggered Workflows + +**Important**: Comment-triggered workflows (`issue_comment` event) only execute from the **default branch** (master). This creates a chicken-and-egg problem when developing workflow changes. + +### Recommended Testing Approach + +1. **Develop the workflow**: Create/modify the workflow in your feature branch +2. **Test locally**: Validate YAML syntax and logic as much as possible +3. **Merge to master**: The workflow must be in master to be triggered by comments +4. **Test on a PR**: Create a test PR and use the comment command to verify + +### Why This Limitation Exists + +GitHub Actions workflows triggered by `issue_comment` events always use the workflow definition from the default branch, not the PR branch. This is a security feature to prevent malicious actors from modifying workflows through PRs. + +For more details, see [GitHub's documentation on issue_comment events](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issue_comment). + +## Available Workflows + +### CI Workflows (Triggered on Push/PR) + +- **`main.yml`** - Main test suite (dummy app integration tests) +- **`lint-js-and-ruby.yml`** - Linting for JavaScript and Ruby code +- **`package-js-tests.yml`** - JavaScript unit tests for the package +- **`rspec-package-specs.yml`** - RSpec tests for the Ruby package +- **`examples.yml`** - Generator tests for example apps +- **`playwright.yml`** - Playwright E2E tests +- **`pro-integration-tests.yml`** - Pro package integration tests +- **`pro-package-tests.yml`** - Pro package unit tests +- **`pro-lint.yml`** - Pro package linting + +### Utility Workflows + +- **`run-skipped-ci.yml`** - Triggered by `/run-skipped-ci` comment on PRs +- **`pr-welcome-comment.yml`** - Auto-comments on new PRs with helpful info +- **`detect-changes.yml`** - Detects which parts of the codebase changed + +### Code Review Workflows + +- **`claude.yml`** - Claude AI code review +- **`claude-code-review.yml`** - Additional Claude code review checks + +### Other Workflows + +- **`check-markdown-links.yml`** - Validates markdown links + +## Workflow Permissions + +Most workflows use minimal permissions. The comment-triggered workflows require: + +- `contents: read` - To read the repository code +- `pull-requests: write` - To post comments and reactions +- `actions: write` - To trigger other workflows + +## Conditional Execution + +Many workflows use change detection to skip unnecessary jobs: + +- Runs all jobs on pushes to `master` +- Runs only relevant jobs on PRs based on changed files +- Can be overridden with `workflow_dispatch` or `/run-skipped-ci` command + +See `script/ci-changes-detector` for the change detection logic. diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index f5ac5f2b13..f7317f3878 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -38,7 +38,9 @@ jobs: examples: needs: detect-changes - if: github.ref == 'refs/heads/master' || needs.detect-changes.outputs.run_generators == 'true' + # Run on master, workflow_dispatch, OR when generators needed + if: | + github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.run_generators == 'true' strategy: fail-fast: false matrix: @@ -46,9 +48,13 @@ jobs: # Always run: Latest versions (fast feedback on PRs) - ruby-version: '3.4' dependency-level: 'latest' - # Master only: Minimum supported versions (full coverage) + # Master and workflow_dispatch: Minimum supported versions (full coverage) - ruby-version: '3.2' dependency-level: 'minimum' + exclude: + # Skip minimum dependency matrix on regular PRs (run only on master/workflow_dispatch) + - ruby-version: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && '3.2' || '' }} + dependency-level: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && 'minimum' || '' }} env: SKIP_YARN_COREPACK_CHECK: 0 BUNDLE_FROZEN: ${{ matrix.dependency-level == 'minimum' && 'false' || 'true' }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3be04b11b1..908cc28c85 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,9 +38,9 @@ jobs: build-dummy-app-webpack-test-bundles: needs: detect-changes - # Run on master OR when tests needed on PR (but skip minimum deps on PR) + # Run on master, workflow_dispatch, OR when tests needed on PR if: | - (github.ref == 'refs/heads/master' || needs.detect-changes.outputs.run_dummy_tests == 'true') + github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.run_dummy_tests == 'true' strategy: matrix: include: @@ -48,10 +48,15 @@ jobs: - ruby-version: '3.4' node-version: '22' dependency-level: 'latest' - # Master only: Minimum supported versions (full coverage) + # Master and workflow_dispatch: Minimum supported versions (full coverage) - ruby-version: '3.2' node-version: '20' dependency-level: 'minimum' + exclude: + # Skip minimum dependency matrix on regular PRs (run only on master/workflow_dispatch) + - ruby-version: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && '3.2' || '' }} + node-version: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && '20' || '' }} + dependency-level: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && 'minimum' || '' }} runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -122,9 +127,9 @@ jobs: dummy-app-integration-tests: needs: [detect-changes, build-dummy-app-webpack-test-bundles] - # Run on master OR when tests needed on PR (but skip minimum deps on PR) + # Run on master, workflow_dispatch, OR when tests needed on PR if: | - (github.ref == 'refs/heads/master' || needs.detect-changes.outputs.run_dummy_tests == 'true') + github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.run_dummy_tests == 'true' strategy: fail-fast: false matrix: @@ -133,10 +138,15 @@ jobs: - ruby-version: '3.4' node-version: '22' dependency-level: 'latest' - # Master only: Minimum supported versions (full coverage) + # Master and workflow_dispatch: Minimum supported versions (full coverage) - ruby-version: '3.2' node-version: '20' dependency-level: 'minimum' + exclude: + # Skip minimum dependency matrix on regular PRs (run only on master/workflow_dispatch) + - ruby-version: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && '3.2' || '' }} + node-version: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && '20' || '' }} + dependency-level: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && 'minimum' || '' }} runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pr-welcome-comment.yml b/.github/workflows/pr-welcome-comment.yml new file mode 100644 index 0000000000..ab5652f48b --- /dev/null +++ b/.github/workflows/pr-welcome-comment.yml @@ -0,0 +1,38 @@ +name: PR Welcome Comment + +on: + pull_request: + types: [opened] + +jobs: + welcome: + # Skip for bots (dependabot, renovate, etc.) + if: github.event.pull_request.user.type != 'Bot' + runs-on: ubuntu-22.04 + permissions: + pull-requests: write + steps: + - name: Add welcome comment with CI command info + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + 👋 Thanks for opening this PR! + + ### 🚀 Running Full CI Suite + + By default, PRs run a subset of CI jobs for faster feedback (latest Ruby/Node versions only). + + To run the **complete CI suite** including all dependency combinations and skipped jobs, comment: + + ``` + /run-skipped-ci + ``` + + This will trigger: + - ✅ Minimum supported versions (Ruby 3.2, Node 20) + - ✅ All example app tests + - ✅ Pro package integration tests + - ✅ All test matrices + + The full CI suite takes longer but ensures compatibility across all supported versions before merging. diff --git a/.github/workflows/pro-integration-tests.yml b/.github/workflows/pro-integration-tests.yml index 8a11cd5d23..2231a814d3 100644 --- a/.github/workflows/pro-integration-tests.yml +++ b/.github/workflows/pro-integration-tests.yml @@ -34,7 +34,7 @@ jobs: # Build webpack test bundles for dummy app build-dummy-app-webpack-test-bundles: needs: detect-changes - if: github.ref == 'refs/heads/master' || needs.detect-changes.outputs.run_pro_tests == 'true' + if: github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.run_pro_tests == 'true' runs-on: ubuntu-22.04 env: REACT_ON_RAILS_PRO_LICENSE: ${{ secrets.REACT_ON_RAILS_PRO_LICENSE }} @@ -124,7 +124,7 @@ jobs: needs: - detect-changes - build-dummy-app-webpack-test-bundles - if: github.ref == 'refs/heads/master' || needs.detect-changes.outputs.run_pro_tests == 'true' + if: github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.run_pro_tests == 'true' runs-on: ubuntu-22.04 env: REACT_ON_RAILS_PRO_LICENSE: ${{ secrets.REACT_ON_RAILS_PRO_LICENSE }} @@ -304,7 +304,7 @@ jobs: needs: - detect-changes - build-dummy-app-webpack-test-bundles - if: github.ref == 'refs/heads/master' || needs.detect-changes.outputs.run_pro_tests == 'true' + if: github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.run_pro_tests == 'true' runs-on: ubuntu-22.04 env: REACT_ON_RAILS_PRO_LICENSE: ${{ secrets.REACT_ON_RAILS_PRO_LICENSE }} diff --git a/.github/workflows/pro-package-tests.yml b/.github/workflows/pro-package-tests.yml index 61579c1e2f..697ed5a01d 100644 --- a/.github/workflows/pro-package-tests.yml +++ b/.github/workflows/pro-package-tests.yml @@ -34,7 +34,7 @@ jobs: # Build webpack test bundles for dummy app build-dummy-app-webpack-test-bundles: needs: detect-changes - if: github.ref == 'refs/heads/master' || needs.detect-changes.outputs.run_pro_tests == 'true' + if: github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.run_pro_tests == 'true' runs-on: ubuntu-22.04 env: REACT_ON_RAILS_PRO_LICENSE: ${{ secrets.REACT_ON_RAILS_PRO_LICENSE }} @@ -124,7 +124,7 @@ jobs: needs: - detect-changes - build-dummy-app-webpack-test-bundles - if: github.ref == 'refs/heads/master' || needs.detect-changes.outputs.run_pro_tests == 'true' + if: github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.run_pro_tests == 'true' runs-on: ubuntu-22.04 # Redis service container services: @@ -205,7 +205,7 @@ jobs: # RSpec tests for Pro package rspec-package-specs: needs: detect-changes - if: github.ref == 'refs/heads/master' || needs.detect-changes.outputs.run_pro_tests == 'true' + if: github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.run_pro_tests == 'true' strategy: matrix: ruby-version: ['3.3.7'] diff --git a/.github/workflows/run-skipped-ci.yml b/.github/workflows/run-skipped-ci.yml new file mode 100644 index 0000000000..13e2afd9fe --- /dev/null +++ b/.github/workflows/run-skipped-ci.yml @@ -0,0 +1,189 @@ +name: Run Full CI Suite + +on: + issue_comment: + types: [created] + +# Prevent concurrent runs per PR +concurrency: + group: full-ci-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + trigger-full-ci: + # Only run on PR comments that match the command + if: | + github.event.issue.pull_request && + ( + startsWith(github.event.comment.body, '/run-skipped-ci') || + contains(github.event.comment.body, '\n/run-skipped-ci') + ) + runs-on: ubuntu-22.04 + permissions: + contents: read + pull-requests: write + actions: write + steps: + - name: Check if user has write access + id: check_access + uses: actions/github-script@v7 + with: + script: | + try { + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor + }); + + const hasAccess = ['admin', 'write'].includes(permission.permission); + console.log(`User ${context.actor} has permission: ${permission.permission}`); + + if (!hasAccess) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `@${context.actor} Sorry, only repository collaborators with write access can trigger full CI runs. 🔒` + }); + } + + return hasAccess; + } catch (error) { + console.error('Error checking permissions:', error); + return false; + } + + - name: Exit if no access + if: steps.check_access.outputs.result == 'false' + run: | + echo "User does not have permission to trigger full CI" + exit 1 + + - name: Add reaction to comment + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ github.event.comment.id }} + reactions: 'rocket' + + - name: Get PR details + id: pr + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + return { + ref: pr.data.head.ref, + sha: pr.data.head.sha + }; + + - name: Trigger all workflows and collect results + id: trigger_workflows + uses: actions/github-script@v7 + with: + script: | + const prData = ${{ steps.pr.outputs.result }}; + const workflows = [ + 'main.yml', + 'examples.yml', + 'pro-integration-tests.yml', + 'pro-package-tests.yml' + ]; + + const succeeded = []; + const failed = []; + + // Trigger all workflows + for (const workflowId of workflows) { + try { + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: workflowId, + ref: prData.ref + }); + console.log(`✅ Triggered ${workflowId}`); + succeeded.push(workflowId); + } catch (error) { + console.error(`❌ Failed to trigger ${workflowId}:`, error.message); + failed.push({ workflow: workflowId, error: error.message }); + } + } + + // Wait a bit for workflows to queue + if (succeeded.length > 0) { + console.log('Waiting 5 seconds for workflows to queue...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + } + + // Verify workflows are queued/running + const verified = []; + const notFound = []; + + if (succeeded.length > 0) { + const runs = await github.rest.actions.listWorkflowRunsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + created: `>${new Date(Date.now() - 60000).toISOString()}` + }); + + for (const workflowId of succeeded) { + const found = runs.data.workflow_runs.some(run => + run.path === `.github/workflows/${workflowId}` && + run.head_sha === prData.sha && + run.event === 'workflow_dispatch' + ); + + if (found) { + verified.push(workflowId); + } else { + notFound.push(workflowId); + } + } + } + + // Build the comment body based on actual results + let status = '✅ **Successfully triggered and verified all workflows**'; + if (failed.length > 0 && notFound.length > 0) { + status = '❌ **Failed to trigger or verify workflows**'; + } else if (failed.length > 0) { + status = '⚠️ **Some workflows failed to trigger**'; + } else if (notFound.length > 0) { + status = '⚠️ **Workflows triggered but not yet verified**'; + } + + const verifiedList = verified.length > 0 ? verified.map(w => `- ✅ ${w}`).join('\n') : ''; + const notFoundList = notFound.length > 0 ? `\n\n**Triggered but not yet queued (may still start):**\n${notFound.map(w => `- ⏳ ${w}`).join('\n')}` : ''; + const failedList = failed.length > 0 ? `\n\n**Failed to trigger:**\n${failed.map(f => `- ❌ ${f.workflow}: ${f.error}`).join('\n')}` : ''; + + const body = `🚀 **Full CI Suite Results** + + ${status} + + ${verifiedList ? `**Verified workflows:**\n${verifiedList}` : ''}${notFoundList}${failedList} + + ${verified.length > 0 ? `\nThese will run all CI jobs including those normally skipped on PRs: + - ✅ Minimum dependency versions (Ruby 3.2, Node 20) + - ✅ All example app tests + - ✅ Pro package integration tests + - ✅ Pro package unit tests + + View progress in the [Actions tab](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions).` : ''}`; + + // Post the comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + + // Fail the job if any workflows failed to trigger + if (failed.length > 0) { + core.setFailed(`Failed to trigger ${failed.length} workflow(s): ${failed.map(f => f.workflow).join(', ')}`); + }