Files
calendar/.github/workflows/pr.yml
T
Keith WilliamsGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
e4753778fe fix(ci): use getCollaboratorPermissionLevel for trust check (#26306)
* fix(ci): use GitHub App token for team membership check

The default GITHUB_TOKEN doesn't have org-level permissions to query
team membership via the teams.getMembershipForUserInOrg API. This causes
the API to return 404 even for valid team members, making the trust check
incorrectly fail for core team members.

This change generates a short-lived GitHub App token with org-level access
to properly verify team membership.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix(ci): use getCollaboratorPermissionLevel for trust check

Replace the team membership check (teams.getMembershipForUserInOrg) with
getCollaboratorPermissionLevel which works with the default GITHUB_TOKEN.

This approach:
- Checks if users have write/maintain/admin access to the repo
- Works without requiring a GitHub App token with org-level permissions
- Is semantically correct: if someone can push to the repo, they're trusted

The previous approach using teams.getMembershipForUserInOrg required
org-level permissions that the default GITHUB_TOKEN doesn't have,
causing 404 errors even for valid team members.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Apply suggestion from @keithwillcode

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-30 08:04:09 -03:00

448 lines
16 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;
// Check if user has write access or higher (admin, maintain, write)
async function hasWriteAccess(username) {
try {
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username,
});
const hasAccess = ['admin', 'maintain', 'write'].includes(permission.permission);
console.log(`User ${username}: hasWriteAccess=${hasAccess}`);
return hasAccess;
} catch (e) {
console.log(`Could not check permission for ${username}: ${e.message}`);
return false;
}
}
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;
}
// Check 2: Verify write access via API (author_association can be unreliable)
if (await hasWriteAccess(pr.user.login)) {
console.log(`Author ${pr.user.login} verified as having write access`);
core.setOutput('is-trusted', true);
return;
}
console.log(`Author ${pr.user.login} does not have write access, checking for approval...`);
// Check 3: Has someone with write access 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 has write access
for (const [reviewer, reviewData] of reviewerStates) {
if (reviewData.state !== 'APPROVED') {
continue;
}
if (await hasWriteAccess(reviewer)) {
console.log(`PR approved by ${reviewer} (has write access) for commit ${headSha}`);
core.setOutput('is-trusted', true);
return;
}
console.log(`Reviewer ${reviewer} does not have write access`);
}
console.log('PR requires approval from someone with write access 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'
)