784db75b38
* ci: optimize sparse-checkout to reduce cache size by ~230MB Exclude additional large files from CI checkout that are not needed for builds/tests: - docs/images/ (~90MB) - Documentation images - packages/app-store/*/static/*.png,jpg,jpeg,gif (~140MB) - App store screenshots These files are only needed for documentation rendering and app store UI display, not for CI builds, linting, type checking, or tests. SVG icons in app-store are preserved as they may be needed for the build. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: exclude generated app-store static files from cache The apps/web/public/app-store directory contains ~151MB of static files copied from packages/app-store/*/static/ during build. These files are regenerated during yarn install and don't need to be cached. Combined with the sparse-checkout exclusions, this should significantly reduce the git checkout cache size. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: fix sparse-checkout by limiting first checkout to .github only The first checkout in the prepare job was doing a full checkout (~535 MB), and then dangerous-git-checkout applied sparse-checkout. But the files from the first checkout remained on disk because sparse-checkout doesn't remove files that were already checked out. By limiting the first checkout to only .github (which is needed to access the local actions), we avoid downloading the full repo twice. The actual sparse-checkout with exclusions is then applied by dangerous-git-checkout. This should reduce the cache from ~504 MB to ~148 MB. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: update cache-checkout to include sparse-checkout exclusions and fix pr.yml compatibility Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
491 lines
18 KiB
YAML
491 lines
18 KiB
YAML
# ⚠️ SECURITY: Do not add steps that checkout PR code or run local actions before trust-check job completes.
|
|
name: PR Update
|
|
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, synchronize, reopened]
|
|
branches:
|
|
- main
|
|
- gh-actions-test-branch
|
|
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
actions: write
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
# Security gate: Check if PR is from a trusted contributor or approved via run-ci label
|
|
# 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
|
|
permissions:
|
|
pull-requests: read
|
|
actions: read
|
|
issues: 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'];
|
|
|
|
if (!context.payload.pull_request) {
|
|
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;
|
|
|
|
// Fetch fresh PR data - payload labels may be stale on re-runs
|
|
const { data: pr } = await github.rest.pulls.get({
|
|
owner,
|
|
repo,
|
|
pull_number: context.payload.pull_request.number,
|
|
});
|
|
|
|
const prNumber = pr.number;
|
|
const headSha = pr.head.sha;
|
|
|
|
async function hasWriteAccess(username) {
|
|
try {
|
|
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
owner,
|
|
repo,
|
|
username,
|
|
});
|
|
return ['admin', 'maintain', 'write'].includes(permission.permission);
|
|
} catch (e) {
|
|
console.log(`Permission check failed for ${username}: ${e.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
console.log(`PR #${prNumber} by ${pr.user.login} (${pr.author_association})`);
|
|
|
|
// Check 1: Is the author a trusted contributor?
|
|
if (trustedAssociations.includes(pr.author_association)) {
|
|
console.log(`Author has trusted association: ${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 has write access`);
|
|
core.setOutput('is-trusted', true);
|
|
return;
|
|
}
|
|
|
|
// Check 3: Was 'run-ci' label added AFTER this SHA was pushed by someone with write access?
|
|
// This enables re-runs triggered by the run-ci.yml workflow
|
|
// NOTE: We use workflow run created_at instead of commit timestamp because
|
|
// git commit timestamps can be arbitrarily backdated by attackers
|
|
if (pr.labels?.some(l => l.name === 'run-ci')) {
|
|
// Skip stale check if this is a re-run (run_attempt > 1)
|
|
// Re-runs are explicitly triggered by maintainers
|
|
const runAttempt = parseInt(process.env.GITHUB_RUN_ATTEMPT || '1', 10);
|
|
if (runAttempt > 1) {
|
|
console.log(`Re-run detected (attempt ${runAttempt}), trusting existing 'run-ci' label`);
|
|
core.setOutput('is-trusted', true);
|
|
return;
|
|
}
|
|
|
|
const events = await github.paginate(github.rest.issues.listEvents, {
|
|
owner,
|
|
repo,
|
|
issue_number: prNumber,
|
|
per_page: 100,
|
|
});
|
|
|
|
const labelEvent = events
|
|
.filter(e => e.event === 'labeled' && e.label?.name === 'run-ci')
|
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
|
|
|
|
if (labelEvent) {
|
|
// Get workflow runs to find when this SHA was first pushed
|
|
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
owner,
|
|
repo,
|
|
workflow_id: 'pr.yml',
|
|
head_sha: headSha,
|
|
per_page: 100,
|
|
});
|
|
|
|
// Filter runs to this PR (in case same SHA exists in multiple PRs)
|
|
const matchingRuns = runs.filter(run =>
|
|
!run.pull_requests?.length || run.pull_requests.some(p => p.number === prNumber)
|
|
);
|
|
|
|
if (matchingRuns.length > 0) {
|
|
const labelTime = new Date(labelEvent.created_at);
|
|
// Use the oldest run's created_at as the push time
|
|
const originalRun = matchingRuns[matchingRuns.length - 1];
|
|
const pushTime = new Date(originalRun.created_at);
|
|
|
|
if (labelTime > pushTime) {
|
|
const adder = labelEvent.actor.login;
|
|
if (await hasWriteAccess(adder)) {
|
|
console.log(`Approved via 'run-ci' label added by ${adder} after push (label: ${labelTime.toISOString()}, push: ${pushTime.toISOString()})`);
|
|
core.setOutput('is-trusted', true);
|
|
return;
|
|
}
|
|
console.log(`Label 'run-ci' added by ${adder} (no write access)`);
|
|
} else {
|
|
console.log(`Label 'run-ci' is stale (label: ${labelTime.toISOString()}, push: ${pushTime.toISOString()})`);
|
|
}
|
|
} else {
|
|
console.log('No workflow runs found for this SHA - cannot validate label timing');
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('External contribution requires "run-ci" label from a maintainer');
|
|
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 }}
|
|
has-api-v2-changes: ${{ steps.filter.outputs.has-api-v2-changes }}
|
|
commit-sha: ${{ steps.get_sha.outputs.commit-sha }}
|
|
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' }}
|
|
db-cache-hit: ${{ steps.cache-db-check.outputs.cache-hit }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
sparse-checkout: .github
|
|
- uses: ./.github/actions/cache-checkout
|
|
- name: Generate DB cache key
|
|
id: cache-db-key
|
|
uses: ./.github/actions/cache-db-key
|
|
- name: Check DB cache (lookup-only)
|
|
id: cache-db-check
|
|
uses: actions/cache/restore@v4
|
|
with:
|
|
path: backups/backup.sql
|
|
key: ${{ steps.cache-db-key.outputs.key }}
|
|
lookup-only: true
|
|
- 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/**"
|
|
has-api-v2-changes:
|
|
- "apps/api/v2/**"
|
|
- "packages/platform-constants/**"
|
|
- "packages/platform-enums/**"
|
|
- "packages/platform-utils/**"
|
|
- "packages/platform-types/**"
|
|
- "packages/platform-libraries/**"
|
|
- "packages/trpc/**"
|
|
- "packages/prisma/schema.prisma"
|
|
- "docs/api-reference/v2/**"
|
|
- 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);
|
|
- uses: ./.github/actions/yarn-install
|
|
if: ${{ steps.filter.outputs.has-files-requiring-all-checks == 'true' }}
|
|
with:
|
|
skip-install-if-cache-hit: "true"
|
|
- uses: ./.github/actions/yarn-playwright-install
|
|
if: ${{ steps.filter.outputs.has-files-requiring-all-checks == 'true' }}
|
|
with:
|
|
skip-install-if-cache-hit: "true"
|
|
|
|
type-check:
|
|
name: Type check
|
|
needs: [prepare]
|
|
if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/check-types.yml
|
|
secrets: inherit
|
|
|
|
lint:
|
|
name: Linters
|
|
needs: [prepare]
|
|
if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/lint.yml
|
|
secrets: inherit
|
|
|
|
unit-test:
|
|
name: Tests
|
|
needs: [prepare]
|
|
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]
|
|
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]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
|
uses: ./.github/workflows/setup-db.yml
|
|
with:
|
|
DB_CACHE_HIT: ${{ needs.prepare.outputs.db-cache-hit }}
|
|
secrets: inherit
|
|
|
|
build-api-v1:
|
|
name: Production builds
|
|
needs: [prepare, 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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
if: ${{ needs.prepare.outputs.run-e2e == 'true' && needs.prepare.outputs.has-api-v2-changes == '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 trust-check did not succeed
|
|
run: |
|
|
echo "::error::Trust check did not complete successfully (result: ${{ needs.trust-check.result }}). Please re-run the workflow."
|
|
exit 1
|
|
if: needs.trust-check.result != 'success'
|
|
- name: Fail if PR is not trusted (external contributor without run-ci label)
|
|
run: |
|
|
echo "::error::This PR is from an external contributor and requires the 'run-ci' label before CI can run."
|
|
echo "A maintainer must review the code and add the 'run-ci' label 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.prepare.outputs.has-api-v2-changes == 'true' && 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'
|
|
)
|