Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/scripts/check-deploy-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { AsyncFunctionArguments } from 'github-script';
import type { PullRequestEvent, PullRequestReviewEvent } 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;

if (!isAllowedAuthor(reviewerAssociation)) {
await skipDeployment(core, 'Not authorized to trigger deployments.');
return;
}

if (event.review.body === 'ok-to-deploy') {
core.setOutput('should-deploy', 'true');
core.info('Deployment allowed: Triggered by maintainer review comment');
return;
}

core.setOutput('should-deploy', 'false');
core.info('No deployment command found in review');
return;
}

if (context.eventName === 'pull_request') {
const event = context.payload as PullRequestEvent;
const authorAssociation = event.pull_request.author_association;

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.'
);
return;
}

core.setOutput('should-deploy', 'true');
core.info('Deployment allowed: Authorized contributor');
return;
}

// no deployment for other events
core.setOutput('should-deploy', 'false');
core.info('Deployment not triggered for this event type');
}

function isAllowedAuthor(authorAssociation: string): boolean {
return (
authorAssociation === 'OWNER' ||
authorAssociation === 'MEMBER' ||
authorAssociation === 'COLLABORATOR'
);
}

async function skipDeployment(coreApi: AsyncFunctionArguments['core'], reason: string): Promise<void> {
coreApi.info('Skipping deployment for security reasons.');
coreApi.setOutput('should-deploy', 'false');
await coreApi.summary
.addQuote(`🚫 Deployment skipped: ${reason}`)
.addDetails(
'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.`
)
.write();
}
10 changes: 7 additions & 3 deletions .github/workflows/deploy-worker/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ inputs:
mode:
required: true
description: 'Deployment mode: `staging` (push to main), `production` (push to release) or `preview` (pull_request to main)'
skip-deploy:
required: false
description: 'Skip actual deployment (only build/test for validation)'
default: 'false'
stagingUrl:
required: true
description: 'Staging URL Origin'
Expand Down Expand Up @@ -56,7 +60,7 @@ runs:
using: 'composite'
steps:
- name: Build worker
if: ${{ (inputs.build && inputs.changed == 'true') || inputs.mode == 'production' }}
if: ${{ inputs.build && (inputs.changed == 'true' || inputs.mode == 'production') }}
shell: bash
run: ${{ inputs.build }}
env:
Expand All @@ -66,7 +70,7 @@ runs:
BUILD_AWS_PREFIX: ${{ inputs.BUILD_AWS_PREFIX }}

- name: Get worker deploy command
if: inputs.changed == 'true' || inputs.mode == 'production'
if: ${{ inputs.skip-deploy == 'false' && (inputs.changed == 'true' || inputs.mode == 'production') }}
id: command
shell: bash
run: |
Expand All @@ -79,7 +83,7 @@ runs:
echo "command=$DEPLOY_COMMAND" >> $GITHUB_OUTPUT

- name: Deploy Worker
if: inputs.changed == 'true' || inputs.mode == 'production'
if: ${{ inputs.skip-deploy == 'false' && (inputs.changed == 'true' || inputs.mode == 'production') }}
id: deploy
uses: cloudflare/wrangler-action@v3
with:
Expand Down
64 changes: 61 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]
paths-ignore: ['**.md']
pull_request_review:
types: [submitted]
branches: [main]

defaults:
run:
Expand All @@ -20,15 +23,32 @@ jobs:
runs-on: ubuntu-latest
steps:
- 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

- name: Check deployment permissions
id: deploy-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 });

- uses: ./.github/workflows/setup

- name: Install wrangler globally
# The wrangler Action expects a global installation, so we better reuse
# what we've in our project.
run: |
wrangler_version=$(cat package.json | jq -r '.pnpm.overrides.wrangler')
pnpm install -g "wrangler@${wrangler_version}"

# https://github.com/dorny/paths-filter/issues/232
- uses: dorny/paths-filter@v3
if: github.event_name != 'pull_request_review'
id: changes
with:
filters: |
Expand All @@ -50,6 +70,41 @@ jobs:
- *cdn
- 'frontend/**'

- name: Detect changes for review-triggered deployment
if: github.event_name == 'pull_request_review'
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 list of changed files in the PR
CHANGED_FILES=$(git diff --name-only $BASE_SHA $HEAD_SHA)

SHARED_CHANGED=$(echo "$CHANGED_FILES" | grep -qE '^(\.github/workflows/(deploy\.yml|deploy-worker/)|shared/)' && echo true || echo false)
API_CHANGED=$(echo "$CHANGED_FILES" | grep -qE '^api/' && echo true || echo false)
COMPONENTS_CHANGED=$(echo "$CHANGED_FILES" | grep -qE '^components/' && echo true || echo false)
CDN_CHANGED=$(echo "$CHANGED_FILES" | grep -qE '^cdn/' && echo true || echo false)
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -qE '^frontend/' && echo true || echo false)

if $SHARED_CHANGED || $API_CHANGED; then
echo "api=true" >> $GITHUB_OUTPUT
else
echo "api=false" >> $GITHUB_OUTPUT
fi

if $SHARED_CHANGED || $API_CHANGED || $COMPONENTS_CHANGED || $CDN_CHANGED; then
echo "cdn=true" >> $GITHUB_OUTPUT
else
echo "cdn=false" >> $GITHUB_OUTPUT
fi

if $SHARED_CHANGED || $API_CHANGED || $COMPONENTS_CHANGED || $CDN_CHANGED || $FRONTEND_CHANGED; then
echo "frontend=true" >> $GITHUB_OUTPUT
else
echo "frontend=false" >> $GITHUB_OUTPUT
fi

- name: Get deploy mode
id: deploy-mode
run: |
Expand Down Expand Up @@ -78,8 +133,9 @@ jobs:
id: api
with:
name: 'api'
changed: ${{ steps.changes.outputs.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' }}
BUILD_AWS_PREFIX: ${{ vars.AWS_PREFIX }}
stagingUrl: ${{ vars.API_STAGING_URL }}
productionUrl: ${{ vars.API_PRODUCTION_URL }}
Expand All @@ -91,8 +147,9 @@ jobs:
id: cdn
with:
name: 'cdn'
changed: ${{ steps.changes.outputs.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' }}
build: 'pnpm -C cdn run build'
BUILD_API_URL: ${{ steps.api.outputs.url }}
BUILD_AWS_PREFIX: ${{ vars.AWS_PREFIX }}
Expand All @@ -106,8 +163,9 @@ jobs:
id: frontend
with:
name: 'frontend'
changed: ${{ steps.changes.outputs.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' }}
build: 'pnpm -C frontend run build'
BUILD_API_URL: ${{ steps.api.outputs.url }}
BUILD_CDN_URL: ${{ steps.cdn.outputs.url }}
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ pnpm -C cdn dev
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.
This will trigger the deploy workflow and create preview environments for the PR.

## Technology Stack

- **Runtime**: Cloudflare workers
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@octokit/webhooks-types": "^7.6.1",
"@types/github-script": "github:actions/github-script#v8.0.0",
"del-cli": "^7.0.0",
"eslint": "^9.39.2",
"eslint-plugin-import": "^2.32.0",
Expand Down
Loading