-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ci): add GitHub Actions workflow for sequential CMS and Web deployment #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ce42058
0d006a7
bf43b04
0675d24
6b09e43
4528bf4
1fd6122
ba84ea4
a570d60
d98901d
00e243f
a6e1930
67397ff
6e25acc
7cb676b
844beef
b741321
3ff8c22
2d1465e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| # Deployment workflow for the JHB Software website. | ||
| # | ||
| # This workflow deploys the CMS before the Web frontend to ensure the frontend | ||
| # always builds against the latest CMS schema and content. The Web frontend | ||
| # fetches data from the CMS at build time, so the CMS must be deployed first. | ||
| # | ||
| # Production: Triggered on push to main | ||
| # Preview: Triggered on pull requests, uses CMS preview URL for Web build | ||
| # | ||
| # Skip builds by adding to commit message: | ||
| # - [skip-cms] - Skip CMS deployment | ||
| # - [skip-web] - Skip Web deployment | ||
| # | ||
| # For CMS-triggered web rebuilds (content updates), use Vercel's deployment hook: | ||
| # Vercel → Web project → Settings → Git → Deploy Hooks | ||
| # | ||
| # Prerequisites: | ||
| # - Disable automatic Vercel deployments for both projects to avoid race conditions | ||
| # - Set up CMS_VERCEL_AUTOMATION_BYPASS_SECRET to allow Web builds to fetch from protected CMS previews | ||
|
|
||
| name: Deploy | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - main | ||
| pull_request: | ||
| types: [opened, synchronize, reopened] | ||
| workflow_dispatch: | ||
| inputs: | ||
| deploy_cms: | ||
| description: 'Deploy CMS' | ||
| required: false | ||
| default: true | ||
| type: boolean | ||
| deploy_web: | ||
| description: 'Deploy Web' | ||
| required: false | ||
| default: true | ||
| type: boolean | ||
| environment: | ||
| description: 'Environment' | ||
| required: false | ||
| default: 'preview' | ||
| type: choice | ||
| options: | ||
| - preview | ||
| - production | ||
|
|
||
| concurrency: | ||
| group: deploy-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| env: | ||
| VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | ||
|
|
||
| jobs: | ||
| detect-changes: | ||
| name: Detect Changes | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| cms_changed: ${{ steps.changes.outputs.cms_changed }} | ||
| web_changed: ${{ steps.changes.outputs.web_changed }} | ||
| skip_cms: ${{ steps.skip.outputs.skip_cms }} | ||
| skip_web: ${{ steps.skip.outputs.skip_web }} | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 2 | ||
|
|
||
| - name: Check for skip tags | ||
| id: skip | ||
| run: | | ||
| COMMIT_MSG="${{ github.event.head_commit.message || github.event.pull_request.title || '' }}" | ||
| echo "skip_cms=$([[ "$COMMIT_MSG" == *"[skip-cms]"* ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT | ||
| echo "skip_web=$([[ "$COMMIT_MSG" == *"[skip-web]"* ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT | ||
|
|
||
| - name: Detect changed files | ||
| id: changes | ||
| run: | | ||
| if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then | ||
| # Manual trigger: use input values | ||
| echo "cms_changed=true" >> $GITHUB_OUTPUT | ||
| echo "web_changed=true" >> $GITHUB_OUTPUT | ||
| else | ||
| # Push/PR: detect actual changes | ||
| if [ "${{ github.event_name }}" == "pull_request" ]; then | ||
| BASE_SHA=${{ github.event.pull_request.base.sha }} | ||
| HEAD_SHA=${{ github.event.pull_request.head.sha }} | ||
| else | ||
| BASE_SHA=${{ github.event.before }} | ||
| HEAD_SHA=${{ github.sha }} | ||
| fi | ||
|
|
||
| CHANGED_FILES=$(git diff --name-only $BASE_SHA $HEAD_SHA 2>/dev/null || echo "") | ||
|
|
||
| # Check if cms/ or web/ folders have changes (or workflow file) | ||
| CMS_CHANGED=$([[ "$CHANGED_FILES" == *"cms/"* ]] || [[ "$CHANGED_FILES" == *".github/workflows/deploy.yml"* ]] && echo 'true' || echo 'false') | ||
| WEB_CHANGED=$([[ "$CHANGED_FILES" == *"web/"* ]] || [[ "$CHANGED_FILES" == *".github/workflows/deploy.yml"* ]] && echo 'true' || echo 'false') | ||
|
|
||
| echo "cms_changed=$CMS_CHANGED" >> $GITHUB_OUTPUT | ||
| echo "web_changed=$WEB_CHANGED" >> $GITHUB_OUTPUT | ||
| echo "Changed files: $CHANGED_FILES" | ||
| fi | ||
|
|
||
| deploy-cms: | ||
| name: Deploy CMS | ||
| runs-on: ubuntu-latest | ||
| needs: detect-changes | ||
| if: | | ||
| (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_cms == 'true') || | ||
| (github.event_name != 'workflow_dispatch' && | ||
| needs.detect-changes.outputs.cms_changed == 'true' && | ||
| needs.detect-changes.outputs.skip_cms == 'false') | ||
| outputs: | ||
| deployment_url: ${{ steps.deploy.outputs.deployment_url }} | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Install Vercel CLI | ||
| run: npm install -g vercel | ||
|
|
||
| - name: Deploy to Vercel | ||
| id: deploy | ||
| run: | | ||
| IS_PROD=${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event.inputs.environment == 'production' }} | ||
| PROD_FLAG="" | ||
| if [ "$IS_PROD" == "true" ]; then | ||
| PROD_FLAG="--prod" | ||
| fi | ||
| DEPLOYMENT_URL=$(vercel deploy $PROD_FLAG --yes --token=${{ secrets.VERCEL_TOKEN }}) | ||
| echo "deployment_url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT | ||
| env: | ||
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_CMS_PROJECT_ID }} | ||
|
|
||
| deploy-web: | ||
| name: Deploy Web | ||
| runs-on: ubuntu-latest | ||
| needs: [detect-changes, deploy-cms] | ||
| if: | | ||
| always() && | ||
| (needs.deploy-cms.result == 'success' || needs.deploy-cms.result == 'skipped') && | ||
| ( | ||
| (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_web == 'true') || | ||
| (github.event_name != 'workflow_dispatch' && | ||
| needs.detect-changes.outputs.web_changed == 'true' && | ||
| needs.detect-changes.outputs.skip_web == 'false') | ||
| ) | ||
| outputs: | ||
| deployment_url: ${{ steps.deploy.outputs.deployment_url }} | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Install Vercel CLI | ||
| run: npm install -g vercel | ||
|
|
||
| - name: Deploy to Vercel | ||
| id: deploy | ||
| run: | | ||
| IS_PROD=${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event.inputs.environment == 'production' }} | ||
| CMS_URL="${{ needs.deploy-cms.outputs.deployment_url }}" | ||
|
|
||
| if [ "$IS_PROD" == "true" ]; then | ||
| DEPLOYMENT_URL=$(vercel deploy --prod --yes --token=${{ secrets.VERCEL_TOKEN }}) | ||
| elif [ -n "$CMS_URL" ]; then | ||
| # Preview with CMS preview URL and bypass secret for Vercel Authentication | ||
| DEPLOYMENT_URL=$(vercel deploy --yes --token=${{ secrets.VERCEL_TOKEN }} \ | ||
| --build-env CMS_URL=$CMS_URL \ | ||
| --build-env CMS_VERCEL_AUTOMATION_BYPASS_SECRET=${{ secrets.CMS_VERCEL_AUTOMATION_BYPASS_SECRET }} \ | ||
| --env CMS_URL=$CMS_URL \ | ||
| --env CMS_VERCEL_AUTOMATION_BYPASS_SECRET=${{ secrets.CMS_VERCEL_AUTOMATION_BYPASS_SECRET }}) | ||
| else | ||
| # Preview without CMS override (uses production CMS from Vercel env vars) | ||
| echo "CMS deployment skipped, using production CMS from Vercel env vars" | ||
| DEPLOYMENT_URL=$(vercel deploy --yes --token=${{ secrets.VERCEL_TOKEN }}) | ||
| fi | ||
| echo "deployment_url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT | ||
| env: | ||
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEB_PROJECT_ID }} | ||
|
|
||
| - name: Deployment Summary | ||
| run: | | ||
| echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY | ||
| echo "" >> $GITHUB_STEP_SUMMARY | ||
| echo "| Project | URL |" >> $GITHUB_STEP_SUMMARY | ||
| echo "|---------|-----|" >> $GITHUB_STEP_SUMMARY | ||
| echo "| CMS | ${{ needs.deploy-cms.outputs.deployment_url || 'Skipped' }} |" >> $GITHUB_STEP_SUMMARY | ||
| echo "| Web | ${{ steps.deploy.outputs.deployment_url }} |" >> $GITHUB_STEP_SUMMARY | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PR comment job skipped when CMS deployment is skippedThe |
||
|
|
||
| comment-preview: | ||
| name: Comment Preview URLs | ||
| runs-on: ubuntu-latest | ||
| needs: [deploy-cms, deploy-web] | ||
| if: ${{ github.event_name == 'pull_request' && needs.deploy-web.result == 'success' }} | ||
| steps: | ||
| - name: Find existing comment | ||
| uses: peter-evans/find-comment@v3 | ||
| id: find-comment | ||
| with: | ||
| issue-number: ${{ github.event.pull_request.number }} | ||
| comment-author: 'github-actions[bot]' | ||
| body-includes: '## Preview Deployment' | ||
|
|
||
| - name: Create or update comment | ||
| uses: peter-evans/create-or-update-comment@v4 | ||
| with: | ||
| comment-id: ${{ steps.find-comment.outputs.comment-id }} | ||
| issue-number: ${{ github.event.pull_request.number }} | ||
| edit-mode: replace | ||
| body: | | ||
| ## Preview Deployment | ||
|
|
||
| | Project | URL | | ||
| |---------|-----| | ||
| | CMS | ${{ needs.deploy-cms.outputs.deployment_url || 'Skipped' }} | | ||
| | Web | ${{ needs.deploy-web.outputs.deployment_url }} | | ||
|
|
||
| The Web preview uses the CMS preview URL for content fetching. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,72 @@ | ||
| # Website of JHB Software | ||
|
|
||
| Monorepository of the JHB Website frontend and content management system (CMS) | ||
|
|
||
| ## Deployment | ||
|
|
||
| This repository uses a GitHub Actions workflow to deploy both the CMS and Web frontend to Vercel. The workflow ensures the CMS is always deployed before the Web frontend, as the frontend fetches data from the CMS at build time. | ||
|
|
||
| ### How It Works | ||
|
|
||
| | Trigger | CMS | Web | Environment | | ||
| |---------|-----|-----|-------------| | ||
| | Push to `main` | If `cms/` changed | If `web/` changed | Production | | ||
| | Pull request | If `cms/` changed | If `web/` changed | Preview | | ||
| | Manual dispatch | Configurable | Configurable | Configurable | | ||
| | Vercel Deploy Hook | No | Yes | Production | | ||
|
|
||
| ### Vercel Setup | ||
|
|
||
| To avoid double deployments (one from Vercel's Git integration and one from GitHub Actions), disable automatic deployments in Vercel: | ||
|
|
||
| 1. Go to **Vercel Dashboard** → **CMS Project** → **Settings** → **Git** | ||
| 2. Under "Deploy Hooks", you can optionally create a hook for CMS-triggered rebuilds | ||
| 3. Under "Ignored Build Step", select **Don't build** (or use the GitHub Actions workflow exclusively) | ||
| 4. Repeat for the **Web Project** | ||
|
|
||
| Alternatively, disconnect the Git repository from Vercel entirely and rely solely on the GitHub Actions workflow for deployments. | ||
|
|
||
| ### CMS-Triggered Web Rebuilds | ||
|
|
||
| When content is updated in the CMS, you can trigger a Web rebuild using Vercel's Deploy Hook: | ||
|
|
||
| 1. Go to **Vercel Dashboard** → **Web Project** → **Settings** → **Git** → **Deploy Hooks** | ||
| 2. Create a new hook (e.g., "CMS Content Update") | ||
| 3. Use the generated URL in your CMS to trigger rebuilds when content is published | ||
|
|
||
| ### Skip Deployments | ||
|
|
||
| Add these tags to your commit message to skip specific deployments: | ||
|
|
||
| - `[skip-cms]` - Skip CMS deployment | ||
| - `[skip-web]` - Skip Web deployment | ||
|
|
||
| Example: `fix(web): update styles [skip-cms]` | ||
|
|
||
| ### Required GitHub Secrets | ||
|
|
||
| | Secret | Description | | ||
| |--------|-------------| | ||
| | `VERCEL_TOKEN` | Vercel API token ([create here](https://vercel.com/account/tokens)) | | ||
| | `VERCEL_ORG_ID` | Vercel Team ID (Settings → General) | | ||
| | `VERCEL_CMS_PROJECT_ID` | CMS project ID (Project Settings → General) | | ||
| | `VERCEL_WEB_PROJECT_ID` | Web project ID (Project Settings → General) | | ||
| | `CMS_VERCEL_AUTOMATION_BYPASS_SECRET` | CMS bypass secret for preview builds (see below) | | ||
|
|
||
| ### Preview Deployments | ||
|
|
||
| For pull requests, the workflow: | ||
| 1. Deploys CMS to a preview URL | ||
| 2. Deploys Web to a preview URL, configured to use the CMS preview URL | ||
| 3. Posts a comment on the PR with both preview URLs | ||
|
|
||
| #### CMS Authentication Bypass | ||
|
|
||
| To allow the Web build to fetch data from protected CMS preview deployments: | ||
|
|
||
| 1. Go to **Vercel Dashboard** → **CMS Project** → **Settings** → **Deployment Protection** | ||
| 2. Enable **Vercel Authentication** (requires Pro plan with Advanced Deployment Protection) | ||
| 3. Copy the **Protection Bypass for Automation** secret | ||
| 4. Add it as `CMS_VERCEL_AUTOMATION_BYPASS_SECRET` in GitHub repository secrets | ||
|
|
||
| This allows the Web build to authenticate with the CMS preview while keeping it protected from public access. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| /** | ||
| * Adds the Vercel automation bypass header to a request if the secret is configured. | ||
| * This allows fetching from CMS preview deployments that have Vercel Authentication enabled. | ||
| */ | ||
| export function addBypassHeader( | ||
| init: RequestInit | undefined, | ||
| bypassSecret: string | undefined, | ||
| ): RequestInit { | ||
| if (!bypassSecret) { | ||
| return init ?? {} | ||
| } | ||
|
|
||
| const headers = new Headers(init?.headers) | ||
| headers.set('x-vercel-protection-bypass', bypassSecret) | ||
|
|
||
| return { | ||
| ...init, | ||
| headers, | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script injection via untrusted commit message or PR title
The commit message and PR title are directly interpolated into a bash script using
${{ github.event.head_commit.message || github.event.pull_request.title }}. This is a well-documented GitHub Actions script injection vulnerability. An attacker can craft a PR title likeFix $(malicious_command)or use backticks, and the shell will execute the embedded command on the runner. For PRs from forks, anyone can control the PR title. The safe pattern is to pass untrusted values via theenv:context instead of direct interpolation.