Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ce42058
feat(ci): add GitHub Actions workflow for deploying CMS and Web appli…
jhb-dev Dec 22, 2025
0d006a7
fix(ci): use Vercel remote build instead of local build
jhb-dev Dec 22, 2025
bf43b04
fix(ci): set VERCEL_PROJECT_ID as env variable
jhb-dev Dec 22, 2025
0675d24
fix(ci): remove working-directory, use Vercel project root settings
jhb-dev Dec 22, 2025
6b09e43
refactor(ci): remove unnecessary ready check step
jhb-dev Dec 22, 2025
4528bf4
docs(ci): add description to deploy workflow
jhb-dev Dec 22, 2025
1fd6122
feat(ci): add preview deployments and concurrency control
jhb-dev Dec 22, 2025
ba84ea4
test(web): add preview deployment banner and console logs
jhb-dev Dec 22, 2025
a570d60
docs(ci): add prerequisites and TODO for bypass secret migration
jhb-dev Dec 22, 2025
d98901d
feat(ci): add Vercel inspect links to PR comment
jhb-dev Dec 22, 2025
00e243f
refactor(ci): simplify and speed up deployment workflow
jhb-dev Dec 22, 2025
a6e1930
fix(ci): improve URL parsing from Vercel output
jhb-dev Dec 22, 2025
67397ff
refactor(ci): simplify by using vercel stdout directly, remove inspec…
jhb-dev Dec 22, 2025
6e25acc
feat(ci): add change detection and skip tags support
jhb-dev Dec 22, 2025
7cb676b
fix(ci): handle empty CMS_URL and fix PROD_FLAG expression
jhb-dev Dec 22, 2025
844beef
test(cms): add dummy sitemap entry to test CMS-only deployment
jhb-dev Dec 22, 2025
b741321
fix(ci): install Vercel CLI globally to avoid npx download
jhb-dev Dec 22, 2025
3ff8c22
feat: add Vercel automation bypass for protected CMS previews
jhb-dev Dec 22, 2025
2d1465e
chore: remove test code from cms and web
jhb-dev Dec 22, 2025
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
221 changes: 221 additions & 0 deletions .github/workflows/deploy.yml
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 || '' }}"
Copy link
Copy Markdown

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 like Fix $(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 the env: context instead of direct interpolation.

Fix in Cursor Fix in Web

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR comment job skipped when CMS deployment is skipped

The comment-preview job has needs: [deploy-cms, deploy-web] but its if condition lacks always(). In GitHub Actions, when any job in needs is skipped, dependent jobs are also skipped by default. With the new change detection logic, deploy-cms can now be skipped for PRs with only web changes, causing comment-preview to be skipped even when deploy-web succeeds. The deploy-web job correctly uses always() at line 143 to handle this, but comment-preview doesn't follow the same pattern.

Fix in Cursor Fix in Web


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.
69 changes: 69 additions & 0 deletions README.md
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.
5 changes: 5 additions & 0 deletions web/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export default defineConfig({
context: 'server',
access: 'public',
}),
CMS_VERCEL_AUTOMATION_BYPASS_SECRET: envField.string({
context: 'server',
access: 'secret',
optional: true,
}),
},
},
})
4 changes: 4 additions & 0 deletions web/src/cms/getRedirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { PayloadSDK } from '@payloadcms/sdk'
import type { RedirectConfig } from 'astro'
import type { Config } from 'cms/src/payload-types'
import 'dotenv/config'
import { addBypassHeader } from './sdk/bypassHeader'

/** Fetches the redirects from the CMS and converts them to the Astro `RedirectConfig` format. */
export async function getRedirects(): Promise<Record<string, RedirectConfig>> {
// Because import.meta.env and astro:env is not available in Astro config files and this method is called from
// the Astro config file, use process.env to access the environment variable instead.
const bypassSecret = process.env.CMS_VERCEL_AUTOMATION_BYPASS_SECRET

const payloadSDK = new PayloadSDK<Config>({
baseURL: process.env.CMS_URL! + '/api',
fetch: (input, init) => fetch(input, addBypassHeader(init, bypassSecret)),
})

const redirectsCms = await payloadSDK.find({
Expand Down
20 changes: 20 additions & 0 deletions web/src/cms/sdk/bypassHeader.ts
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,
}
}
8 changes: 6 additions & 2 deletions web/src/cms/sdk/cachedFetch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CMS_VERCEL_AUTOMATION_BYPASS_SECRET } from 'astro:env/server'
import { addBypassHeader } from './bypassHeader'
import { cache } from './cache'

/**
Expand All @@ -20,6 +22,8 @@ export function createCachedFetch(baseFetch: typeof fetch): typeof fetch {
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
// Add bypass header for Vercel Authentication
const initWithBypass = addBypassHeader(init, CMS_VERCEL_AUTOMATION_BYPASS_SECRET)
// Convert input to URL string for caching
const url =
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
Expand Down Expand Up @@ -55,7 +59,7 @@ export function createCachedFetch(baseFetch: typeof fetch): typeof fetch {
}

// Make the actual request
const response = await baseFetch(input, init)
const response = await baseFetch(input, initWithBypass)

if (response.ok) {
// Clone the response so we can read it and still return it
Expand All @@ -72,6 +76,6 @@ export function createCachedFetch(baseFetch: typeof fetch): typeof fetch {
}

// For non-GET requests or when cache is not explicitly enabled, just forward the request
return baseFetch(input, init)
return baseFetch(input, initWithBypass)
}
}
Loading