diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..d52943b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 + + 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. diff --git a/README.md b/README.md index 19b3fd8..cb03b3e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/web/astro.config.mjs b/web/astro.config.mjs index 9ead447..b800654 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -28,6 +28,11 @@ export default defineConfig({ context: 'server', access: 'public', }), + CMS_VERCEL_AUTOMATION_BYPASS_SECRET: envField.string({ + context: 'server', + access: 'secret', + optional: true, + }), }, }, }) diff --git a/web/src/cms/getRedirects.ts b/web/src/cms/getRedirects.ts index 23ca610..32be50d 100644 --- a/web/src/cms/getRedirects.ts +++ b/web/src/cms/getRedirects.ts @@ -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> { // 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({ baseURL: process.env.CMS_URL! + '/api', + fetch: (input, init) => fetch(input, addBypassHeader(init, bypassSecret)), }) const redirectsCms = await payloadSDK.find({ diff --git a/web/src/cms/sdk/bypassHeader.ts b/web/src/cms/sdk/bypassHeader.ts new file mode 100644 index 0000000..41271b3 --- /dev/null +++ b/web/src/cms/sdk/bypassHeader.ts @@ -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, + } +} diff --git a/web/src/cms/sdk/cachedFetch.ts b/web/src/cms/sdk/cachedFetch.ts index f731b14..ce07799 100644 --- a/web/src/cms/sdk/cachedFetch.ts +++ b/web/src/cms/sdk/cachedFetch.ts @@ -1,3 +1,5 @@ +import { CMS_VERCEL_AUTOMATION_BYPASS_SECRET } from 'astro:env/server' +import { addBypassHeader } from './bypassHeader' import { cache } from './cache' /** @@ -20,6 +22,8 @@ export function createCachedFetch(baseFetch: typeof fetch): typeof fetch { input: RequestInfo | URL, init?: RequestInit, ): Promise { + // 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 @@ -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 @@ -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) } }