This change adds a trust-check job that gates CI for external contributors: - Trusted contributors (OWNER, MEMBER, COLLABORATOR) can run CI immediately - External contributors require approval from a core team member (write access or higher) - Approval must be for the current commit SHA (stale approvals are ignored) - Added pull_request_review trigger so CI runs when approval is given - The required job now fails with a clear message for untrusted PRs Co-authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> Co-authored-by: Pedro Castro <pedro@cal.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
436 lines
15 KiB
YAML
436 lines
15 KiB
YAML
name: PR Update
|
|
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, synchronize, reopened]
|
|
branches:
|
|
- main
|
|
- gh-actions-test-branch
|
|
# Allow CI to run when a maintainer approves an external contributor's PR
|
|
pull_request_review:
|
|
types: [submitted]
|
|
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
actions: write
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'pull_request_review' && github.event.review.state != 'approved' && '-noop' || '' }}
|
|
cancel-in-progress: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'approved' }}
|
|
|
|
jobs:
|
|
# Security gate: Check if PR is from a trusted contributor or has been approved by a core team member
|
|
# This MUST run before any job that checks out PR code and executes it with secrets
|
|
trust-check:
|
|
name: Trust Check
|
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
|
# Skip if this is a non-approval review event (e.g., comment or changes_requested)
|
|
if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved'
|
|
permissions:
|
|
pull-requests: read
|
|
outputs:
|
|
is-trusted: ${{ steps.check-trust.outputs.is-trusted }}
|
|
steps:
|
|
- name: Check if PR is trusted
|
|
id: check-trust
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
|
const pr = context.payload.pull_request;
|
|
|
|
if (!pr) {
|
|
if (context.eventName === 'workflow_dispatch') {
|
|
console.log('workflow_dispatch event - assuming trusted (manual trigger)');
|
|
core.setOutput('is-trusted', true);
|
|
return;
|
|
}
|
|
console.log('No pull request context found');
|
|
core.setOutput('is-trusted', false);
|
|
return;
|
|
}
|
|
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
const prNumber = pr.number;
|
|
const headSha = pr.head.sha;
|
|
|
|
console.log(`PR #${prNumber} by ${pr.user.login}`);
|
|
console.log(`Author association: ${pr.author_association}`);
|
|
console.log(`Head SHA: ${headSha}`);
|
|
|
|
// Check 1: Is the author a trusted contributor?
|
|
const isTrustedAuthor = trustedAssociations.includes(pr.author_association);
|
|
if (isTrustedAuthor) {
|
|
console.log(`Author ${pr.user.login} is trusted (${pr.author_association})`);
|
|
core.setOutput('is-trusted', true);
|
|
return;
|
|
}
|
|
|
|
console.log(`Author ${pr.user.login} is external (${pr.author_association}), checking for core team approval...`);
|
|
|
|
// Check 2: Has a core team member approved the current commit?
|
|
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
|
owner,
|
|
repo,
|
|
pull_number: prNumber,
|
|
per_page: 100,
|
|
});
|
|
|
|
// Group reviews by reviewer and get their latest state for the current commit
|
|
const reviewerStates = new Map();
|
|
for (const review of reviews) {
|
|
// Only consider reviews for the current head SHA
|
|
if (review.commit_id !== headSha) {
|
|
continue;
|
|
}
|
|
|
|
// Only track APPROVED and CHANGES_REQUESTED states
|
|
if (!['APPROVED', 'CHANGES_REQUESTED'].includes(review.state)) {
|
|
continue;
|
|
}
|
|
|
|
const reviewer = review.user.login;
|
|
const existing = reviewerStates.get(reviewer);
|
|
|
|
// Keep the latest review (higher ID = more recent)
|
|
if (!existing || review.id > existing.id) {
|
|
reviewerStates.set(reviewer, {
|
|
id: review.id,
|
|
state: review.state,
|
|
login: reviewer,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check if any approver is a core team member (has write access or higher)
|
|
for (const [reviewer, reviewData] of reviewerStates) {
|
|
if (reviewData.state !== 'APPROVED') {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
owner,
|
|
repo,
|
|
username: reviewer,
|
|
});
|
|
|
|
const isCoreTeam = ['admin', 'maintain', 'write'].includes(permission.permission);
|
|
console.log(`Reviewer ${reviewer}: permission=${permission.permission}, isCoreTeam=${isCoreTeam}`);
|
|
|
|
if (isCoreTeam) {
|
|
console.log(`PR approved by core team member ${reviewer} for commit ${headSha}`);
|
|
core.setOutput('is-trusted', true);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.log(`Could not check permission for ${reviewer}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
console.log('PR requires approval from a core team member before CI can run');
|
|
core.setOutput('is-trusted', false);
|
|
|
|
prepare:
|
|
name: Prepare
|
|
needs: [trust-check]
|
|
if: needs.trust-check.outputs.is-trusted == 'true'
|
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
|
permissions:
|
|
pull-requests: read
|
|
outputs:
|
|
has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }}
|
|
has_companion: ${{ steps.filter.outputs.has_companion }}
|
|
commit-sha: ${{ steps.get_sha.outputs.commit-sha }}
|
|
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: ./.github/actions/dangerous-git-checkout
|
|
- uses: dorny/paths-filter@v3
|
|
id: filter
|
|
with:
|
|
predicate-quantifier: "every"
|
|
filters: |
|
|
has-files-requiring-all-checks:
|
|
- '**'
|
|
- '!companion/**'
|
|
- '!**/*.md'
|
|
- '!**/*.mdx'
|
|
- '!.github/CODEOWNERS'
|
|
- '!docs/**'
|
|
- '!help/**'
|
|
- '!apps/web/public/static/locales/**/common.json'
|
|
- '!i18n.lock'
|
|
has_companion:
|
|
- "companion/**"
|
|
- name: Get Latest Commit SHA
|
|
id: get_sha
|
|
run: |
|
|
echo "commit-sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
|
- name: Check if PR exists with ready-for-e2e label for this SHA
|
|
id: check-if-pr-has-label
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
let labels = [];
|
|
let prNumber = null;
|
|
|
|
if (context.payload.pull_request) {
|
|
prNumber = context.payload.pull_request.number;
|
|
} else {
|
|
try {
|
|
const sha = '${{ steps.get_sha.outputs.commit-sha }}';
|
|
console.log('sha', sha);
|
|
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
commit_sha: sha
|
|
});
|
|
|
|
if (prs.length === 0) {
|
|
core.setOutput('run-e2e', false);
|
|
console.log(`No pull requests found for commit SHA ${sha}`);
|
|
return;
|
|
}
|
|
|
|
prNumber = prs[0].number;
|
|
}
|
|
catch (e) {
|
|
core.setOutput('run-e2e', false);
|
|
console.log(e);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Always fetch fresh PR data to get current labels
|
|
// This avoids stale label data from event payloads
|
|
try {
|
|
const { data: pr } = await github.rest.pulls.get({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: prNumber
|
|
});
|
|
|
|
console.log(`PR number: ${pr.number}`);
|
|
console.log(`PR title: ${pr.title}`);
|
|
console.log(`PR state: ${pr.state}`);
|
|
console.log(`PR URL: ${pr.html_url}`);
|
|
|
|
labels = pr.labels;
|
|
}
|
|
catch (e) {
|
|
core.setOutput('run-e2e', false);
|
|
console.log(e);
|
|
return;
|
|
}
|
|
|
|
const labelFound = labels.map(l => l.name).includes('ready-for-e2e');
|
|
console.log('Found the label?', labelFound);
|
|
core.setOutput('run-e2e', labelFound);
|
|
|
|
deps:
|
|
name: Install dependencies
|
|
needs: [prepare]
|
|
if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/yarn-install.yml
|
|
|
|
type-check:
|
|
name: Type check
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/check-types.yml
|
|
secrets: inherit
|
|
|
|
lint:
|
|
name: Linters
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/lint.yml
|
|
secrets: inherit
|
|
|
|
unit-test:
|
|
name: Tests
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/unit-tests.yml
|
|
secrets: inherit
|
|
|
|
api-v2-unit-test:
|
|
name: Tests
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/api-v2-unit-tests.yml
|
|
secrets: inherit
|
|
|
|
setup-db:
|
|
name: Setup Database
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/setup-db.yml
|
|
with:
|
|
COMMIT_SHA: ${{ needs.prepare.outputs.commit-sha }}
|
|
secrets: inherit
|
|
|
|
build-api-v1:
|
|
name: Production builds
|
|
needs: [prepare, deps, setup-db]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/api-v1-production-build.yml
|
|
secrets: inherit
|
|
|
|
build-api-v2:
|
|
name: Production builds
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/api-v2-production-build.yml
|
|
secrets: inherit
|
|
|
|
build-atoms:
|
|
name: Production builds
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/atoms-production-build.yml
|
|
secrets: inherit
|
|
|
|
build-docs:
|
|
name: Production builds
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/docs-build.yml
|
|
secrets: inherit
|
|
|
|
build-companion:
|
|
name: Companion builds
|
|
needs: [prepare]
|
|
if: needs.prepare.outputs.has_companion == 'true'
|
|
uses: ./.github/workflows/companion-build.yml
|
|
secrets: inherit
|
|
|
|
build:
|
|
name: Production builds
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/production-build-without-database.yml
|
|
secrets: inherit
|
|
|
|
integration-test:
|
|
name: Tests
|
|
needs: [prepare, build, setup-db]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/integration-tests.yml
|
|
secrets: inherit
|
|
|
|
e2e:
|
|
name: Tests
|
|
needs: [prepare, build, setup-db]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/e2e.yml
|
|
secrets: inherit
|
|
|
|
check-api-v2-breaking-changes:
|
|
name: Check API v2 breaking changes
|
|
needs: [prepare, deps]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/check-api-v2-breaking-changes.yml
|
|
secrets: inherit
|
|
|
|
e2e-api-v2:
|
|
name: Tests
|
|
needs: [prepare, setup-db]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/e2e-api-v2.yml
|
|
secrets: inherit
|
|
|
|
e2e-app-store:
|
|
name: Tests
|
|
needs: [prepare, build, setup-db]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/e2e-app-store.yml
|
|
secrets: inherit
|
|
|
|
e2e-embed:
|
|
name: Tests
|
|
needs: [prepare, build, setup-db]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/e2e-embed.yml
|
|
secrets: inherit
|
|
|
|
e2e-embed-react:
|
|
name: Tests
|
|
needs: [prepare, build, setup-db]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/e2e-embed-react.yml
|
|
secrets: inherit
|
|
|
|
analyze:
|
|
name: Analyze Build
|
|
needs: [build]
|
|
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
|
secrets: inherit
|
|
|
|
required:
|
|
needs:
|
|
[
|
|
trust-check,
|
|
prepare,
|
|
lint,
|
|
type-check,
|
|
unit-test,
|
|
api-v2-unit-test,
|
|
check-api-v2-breaking-changes,
|
|
integration-test,
|
|
build,
|
|
build-api-v1,
|
|
build-api-v2,
|
|
build-atoms,
|
|
build-docs,
|
|
build-companion,
|
|
setup-db,
|
|
e2e,
|
|
e2e-api-v2,
|
|
e2e-embed,
|
|
e2e-embed-react,
|
|
e2e-app-store,
|
|
]
|
|
if: always()
|
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
|
steps:
|
|
- name: Fail if PR is not trusted (external contributor without approval)
|
|
run: |
|
|
echo "::error::This PR is from an external contributor and requires approval from a core team member before CI can run."
|
|
echo "A maintainer with write access must approve this PR to trigger CI checks."
|
|
exit 1
|
|
if: needs.trust-check.outputs.is-trusted != 'true' && needs.trust-check.result == 'success'
|
|
- name: fail if conditional jobs failed
|
|
run: exit 1
|
|
if: |
|
|
(
|
|
needs.prepare.outputs.has-files-requiring-all-checks == 'true' &&
|
|
(
|
|
needs.lint.result != 'success' ||
|
|
needs.type-check.result != 'success' ||
|
|
needs.unit-test.result != 'success' ||
|
|
needs.api-v2-unit-test.result != 'success' ||
|
|
needs.check-api-v2-breaking-changes.result != 'success' ||
|
|
needs.build.result != 'success' ||
|
|
needs.build-api-v1.result != 'success' ||
|
|
needs.build-api-v2.result != 'success' ||
|
|
needs.build-atoms.result != 'success' ||
|
|
needs.build-docs.result != 'success' ||
|
|
needs.setup-db.result != 'success' ||
|
|
needs.integration-test.result != 'success' ||
|
|
needs.e2e.result != 'success' ||
|
|
needs.e2e-api-v2.result != 'success' ||
|
|
needs.e2e-embed.result != 'success' ||
|
|
needs.e2e-embed-react.result != 'success' ||
|
|
needs.e2e-app-store.result != 'success'
|
|
)
|
|
) ||
|
|
(
|
|
needs.prepare.outputs.has_companion == 'true' &&
|
|
needs.build-companion.result != 'success'
|
|
)
|