e4753778fe
* 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>
448 lines
16 KiB
YAML
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'
|
|
)
|