diff --git a/.github/scripts/check-deploy-permissions.ts b/.github/scripts/check-deploy-permissions.ts index 911856f1..618e992b 100644 --- a/.github/scripts/check-deploy-permissions.ts +++ b/.github/scripts/check-deploy-permissions.ts @@ -1,24 +1,46 @@ import type { AsyncFunctionArguments } from 'github-script'; -import type { PullRequestEvent, PullRequestReviewEvent } from '@octokit/webhooks-types'; +import type { PullRequestEvent, IssueCommentEvent } from '@octokit/webhooks-types'; -export default async function checkDeployPermissions({ core, context }: AsyncFunctionArguments) { - if (context.eventName === 'pull_request_review') { - const event = context.payload as PullRequestReviewEvent; - const reviewerAssociation = event.review.author_association; +export default async function checkDeployPermissions({ core, context, github }: AsyncFunctionArguments) { + if (context.eventName === 'issue_comment') { + const event = context.payload as IssueCommentEvent; - if (!isAllowedAuthor(reviewerAssociation)) { - await skipDeployment(core, 'Not authorized to trigger deployments.'); + if (!event.issue.pull_request) { + core.setOutput('should-deploy', 'false'); + core.info('Comment is not on a pull request'); + return; + } + + if (event.comment.body?.trim() !== 'ok-to-deploy') { + core.setOutput('should-deploy', 'false'); + core.info('Comment is not the deployment command'); return; } - if (event.review.body === 'ok-to-deploy') { - core.setOutput('should-deploy', 'true'); - core.info('Deployment allowed: Triggered by maintainer review comment'); + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: event.comment.user.login + }); + + const isAuthorized = ['admin', 'maintain', 'write'].includes(permission.permission); + + if (!isAuthorized) { + await skipDeployment(core, 'Not authorized to trigger deployments.'); return; } - core.setOutput('should-deploy', 'false'); - core.info('No deployment command found in review'); + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: event.issue.number + }); + + core.setOutput('should-deploy', 'true'); + core.setOutput('pr-number', pr.number); + core.setOutput('pr-head-sha', pr.head.sha); + core.setOutput('pr-base-sha', pr.base.sha); + core.info(`Deployment allowed: "ok-to-deploy" comment from authorized user @${event.comment.user.login}`); return; } @@ -29,7 +51,7 @@ export default async function checkDeployPermissions({ core, context }: AsyncFun if (!isAllowedAuthor(authorAssociation)) { await skipDeployment( core, - 'The PR author is not authorized to run deployments. Maintainers can trigger a deployment by submitting a review with "pull-request-review" in the comment.' + 'The PR author is not authorized to run deployments. Maintainers can trigger a deployment by commenting "ok-to-deploy" on the pull request.' ); return; } @@ -39,6 +61,12 @@ export default async function checkDeployPermissions({ core, context }: AsyncFun return; } + if (context.eventName === 'push') { + core.setOutput('should-deploy', 'true'); + core.info('Deployment allowed: Push to protected branch'); + return; + } + // no deployment for other events core.setOutput('should-deploy', 'false'); core.info('Deployment not triggered for this event type'); @@ -61,7 +89,7 @@ async function skipDeployment(coreApi: AsyncFunctionArguments['core'], reason: s 'Security Notice', `Deployments are restricted to organization members, collaborators, and repository owners. External contributors can still run builds and tests. - Maintainers can trigger deployments by reviewing the PR with "pull-request-review" in the comment.` + Maintainers can trigger deployments by adding a regular PR comment with "ok-to-deploy" exactly.` ) .write(); } diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bc796e06..ee7a9244 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,9 +6,8 @@ on: pull_request: branches: [main] paths-ignore: ['**.md'] - pull_request_review: - types: [submitted] - branches: [main] + issue_comment: + types: [created] defaults: run: @@ -16,27 +15,48 @@ defaults: permissions: pull-requests: write + issues: read + contents: read jobs: - deploy: - name: Deploy + check-permissions: + name: Check Permissions runs-on: ubuntu-latest + if: | + github.event_name == 'push' || + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request) + outputs: + should-deploy: ${{ steps.check.outputs.should-deploy }} + pr-number: ${{ steps.check.outputs.pr-number }} + pr-head-sha: ${{ steps.check.outputs.pr-head-sha }} + pr-base-sha: ${{ steps.check.outputs.pr-base-sha }} steps: - - uses: actions/checkout@v6 + - name: Checkout base branch for security check + uses: actions/checkout@v6 with: - # Ensures we checkout the PR head commit for both pull_request and pull_request_review events - ref: ${{ github.event.pull_request.head.sha || github.sha }} - # Fetches the full git history, both base and head SHAs are available - fetch-depth: 0 + fetch-depth: 1 - name: Check deployment permissions - id: deploy-check + id: check uses: actions/github-script@v8 with: script: | const script = await import('${{ github.workspace }}/.github/scripts/check-deploy-permissions.ts'); - await script.default({ core, context }); - + await script.default({ core, context, github }); + + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: check-permissions + if: needs.check-permissions.outputs.should-deploy == 'true' + steps: + # warning: untrusted code, checkout the PR head only after auth check passed + - uses: actions/checkout@v6 + with: + ref: ${{ needs.check-permissions.outputs.pr-head-sha || github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + - uses: ./.github/workflows/setup - name: Install wrangler globally @@ -48,7 +68,7 @@ jobs: # https://github.com/dorny/paths-filter/issues/232 - uses: dorny/paths-filter@v3 - if: github.event_name != 'pull_request_review' + if: github.event_name != 'issue_comment' id: changes with: filters: | @@ -70,13 +90,13 @@ jobs: - *cdn - 'frontend/**' - - name: Detect changes for review-triggered deployment - if: github.event_name == 'pull_request_review' + - name: Detect changes for issue_comment deployment + if: github.event_name == 'issue_comment' id: changes-review run: | - # Get the PR base and head SHAs - BASE_SHA=${{ github.event.pull_request.base.sha }} - HEAD_SHA=${{ github.event.pull_request.head.sha }} + # Get the PR base and head SHAs from the check-permissions job outputs + BASE_SHA=${{ needs.check-permissions.outputs.pr-base-sha }} + HEAD_SHA=${{ needs.check-permissions.outputs.pr-head-sha }} # Get list of changed files in the PR CHANGED_FILES=$(git diff --name-only $BASE_SHA $HEAD_SHA) @@ -135,7 +155,7 @@ jobs: name: 'api' changed: ${{ steps.changes.outputs.api || steps.changes-review.outputs.api }} mode: ${{ steps.deploy-mode.outputs.mode }} - skip-deploy: ${{ steps.deploy-check.outputs.should-deploy == 'false' }} + skip-deploy: 'false' BUILD_AWS_PREFIX: ${{ vars.AWS_PREFIX }} stagingUrl: ${{ vars.API_STAGING_URL }} productionUrl: ${{ vars.API_PRODUCTION_URL }} @@ -149,7 +169,7 @@ jobs: name: 'cdn' changed: ${{ steps.changes.outputs.cdn || steps.changes-review.outputs.cdn }} mode: ${{ steps.deploy-mode.outputs.mode }} - skip-deploy: ${{ steps.deploy-check.outputs.should-deploy == 'false' }} + skip-deploy: 'false' build: 'pnpm -C cdn run build' BUILD_API_URL: ${{ steps.api.outputs.url }} BUILD_AWS_PREFIX: ${{ vars.AWS_PREFIX }} @@ -165,7 +185,7 @@ jobs: name: 'frontend' changed: ${{ steps.changes.outputs.frontend || steps.changes-review.outputs.frontend }} mode: ${{ steps.deploy-mode.outputs.mode }} - skip-deploy: ${{ steps.deploy-check.outputs.should-deploy == 'false' }} + skip-deploy: 'false' build: 'pnpm -C frontend run build' BUILD_API_URL: ${{ steps.api.outputs.url }} BUILD_CDN_URL: ${{ steps.cdn.outputs.url }} @@ -222,11 +242,11 @@ jobs: } - name: Create deploy comment - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' || github.event_name == 'issue_comment' continue-on-error: true uses: edumserrano/find-create-or-update-comment@v3 with: - issue-number: ${{ github.event.pull_request.number }} + issue-number: ${{ github.event.pull_request.number || needs.check-permissions.outputs.pr-number }} body-includes: '' comment-author: 'github-actions[bot]' body: | diff --git a/README.md b/README.md index fecb4584..7198c85a 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ pnpm -C localenv/s3 dev ### How to Run Preview Changes -For a pull request, **external contributors** (those without write access to the repository), deployment previews are not automatically. However, user with write access to repository can trigger the workflow **preview deployments** by adding a review-comment with body `ok-to-deploy` exactly. +For a pull request, **external contributors** (those without write access to the repository), deployment previews are not triggered automatically. However, users with write access to the repository can trigger **preview deployments** by adding a regular PR comment with `ok-to-deploy` exactly. This will trigger the deploy workflow and create preview environments for the PR. ## Technology Stack