Skip to content

ci: add reusable Devin session checking action with age limit #99775

ci: add reusable Devin session checking action with age limit

ci: add reusable Devin session checking action with age limit #99775

Workflow file for this run

# ⚠️ 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-exclusions.outputs.has-files-requiring-all-checks }}
has-companion: ${{ steps.filter-inclusions.outputs.has-companion }}
has-api-v2-changes: ${{ steps.filter-inclusions.outputs.has-api-v2-changes }}
has-prisma-changes: ${{ steps.filter-inclusions.outputs.has-prisma-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
- name: Check for files requiring all checks (with exclusions)
uses: dorny/paths-filter@v3
id: filter-exclusions
with:
predicate-quantifier: "every"
filters: |
has-files-requiring-all-checks:
- '**'
- '!companion/**'
- '!.vscode/**'
- '!**/*.md'
- '!**/*.mdx'
- '!.github/CODEOWNERS'
- '!docs/**'
- '!help/**'
- '!apps/web/public/static/locales/**/common.json'
- '!i18n.lock'
- name: Check for specific path changes
uses: dorny/paths-filter@v3
id: filter-inclusions
with:
filters: |
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/**"
has-prisma-changes:
- "packages/prisma/schema.prisma"
- "packages/prisma/migrations/**"
- 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-exclusions.outputs.has-files-requiring-all-checks == 'true' }}
with:
skip-install-if-cache-hit: "true"
- uses: ./.github/actions/yarn-playwright-install
if: ${{ steps.filter-exclusions.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
check-prisma-migrations:
name: Check Prisma Migrations
needs: [prepare]
if: ${{ needs.prepare.outputs.has-prisma-changes == 'true' }}
uses: ./.github/workflows/check-prisma-migrations.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,
check-prisma-migrations,
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.prepare.outputs.has-prisma-changes == 'true' && needs.check-prisma-migrations.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'
)