Content-Aware GitHub Actions Deployment for Next.js Blog

How to set up GitHub Actions workflows that intelligently handle content vs code changes, with automatic PR creation and selective deployments

Content-Aware GitHub Actions Deployment for Next.js Blog

Building a Smart GitHub Actions Workflow for a Next.js Blog

When building a blog with Next.js and AWS CDK, we want our deployment process to be both efficient and maintainable. This article explains how we built a comprehensive GitHub Actions workflow system that:

  • When deploying a Next.js blog with infrastructure as code, we often face a challenge: should we run the full deployment process for every change, even when we're just updating a blog post? This article explains how we built a smart GitHub Actions workflow that:

    • Differentiates between content and code changes
    • Skips unnecessary builds and deployments for content-only updates
    • Automatically creates and labels pull requests
    • Handles deployments to both development and production environments
    • Maintains clean git history with temporary release branches

Workflow Structure

Our deployment process uses four interconnected workflows:

  • develop.yml: Handles development deployments and content detection
  • create-pr.yml: Creates release branches and PRs from develop to main
  • auto-merge.yml: Manages PR merging, branch cleanup, and develop sync
  • production.yml: Handles production deployments from main

Each workflow has a specific responsibility:

Success

Creates PR

Merges to main

develop.yml

create-pr.yml

auto-merge.yml

production.yml

Project Structure

Our blog uses a monorepo structure with Yarn workspaces:

.
├── apps/
│   ├── web/                 # Next.js application
│   │   ├── src/
│   │   │   ├── content/    # Blog content (MDX)
│   │   │   └── lib/        # Utilities
│   │   └── public/
│   │       └── images/     # Static images
│   └── infra/              # CDK infrastructure code
│       ├── src/
│       └── dist/           # Compiled CDK code
├── packages/               # Shared packages (if any)
└── package.json           # Root package.json

Development Workflow Optimizations

The development workflow includes several optimizations to prevent unnecessary builds:

on:
  push:
    branches:
      - develop
    paths-ignore:
      - "**/*.md"
  pull_request:
    branches:
      - develop
 
jobs:
  deploy:
    if: "!contains(github.event.head_commit.message, '[skip ci]')"

This configuration:

  1. Ignores markdown file changes
  2. Skips deployment when commit message contains [skip ci]
  3. Prevents deployment loops during branch syncing

Content Detection Implementation

The first challenge was implementing reliable content change detection. We use GitHub Actions to check if changes are limited to content files:

- name: Check Content Changes
  id: check-changes
  uses: actions/github-script@v7
  with:
    script: |
      const contentPaths = [
        'apps/web/src/content/',
        'apps/web/public/images/'
      ];
 
      let changedFiles = [];
      if (context.eventName === 'push') {
        const commitSHA = context.payload.after;
        const { data: commit } = await github.rest.repos.getCommit({
          owner: context.repo.owner,
          repo: context.repo.repo,
          ref: commitSHA
        });
        changedFiles = commit.files.map(f => f.filename);
      } else if (context.eventName === 'pull_request') {
        const { data: files } = await github.rest.pulls.listFiles({
          owner: context.repo.owner,
          repo: context.repo.repo,
          pull_number: context.issue.number
        });
        changedFiles = files.map(f => f.filename);
      }
 
      const isContentOnly = changedFiles.length > 0 && 
        changedFiles.every(file => 
          contentPaths.some(path => file.startsWith(path))
        );
 
      core.setOutput('is-content-only', isContentOnly);

This script:

  1. Defines content paths (blog posts and images)
  2. Gets changed files from either push or PR events
  3. Checks if all changes are within content paths
  4. Sets an output variable used by later steps

Workflow Overview

Our workflow consists of three main phases: Development, PR Creation, and Merge Process.

1. Development Phase

Development

Yes

No

Push to develop

Triggers develop.yml

Content Only?

Skip build/deploy

Build & deploy to dev

Development Success

2. PR Creation Phase

PR Creation

Content

Code

Development Success

Triggers create-pr.yml

Create release/YYYYMMDD-HHMMSS branch

Check content changes

Create PR to main

Change Type?

Label: auto-approve + content-only

Label: needs-review

3. Merge Process

Merge Flow

Content PR

Code PR

Yes

PR Type?

Triggers auto-merge.yml

Await manual review

Mergeable?

Merge PR to main

Checkout develop

Sync with main

Push develop with 'skip ci'

This workflow provides:

  • Clear separation of concerns between development and production
  • Automated handling of content updates
  • Safe synchronization between branches
  • Prevention of deployment loops

Key Features:

  • Content-only changes skip unnecessary builds
  • Unique release branches for clean PR history
  • Automatic syncing of develop with main
  • Manual review requirement for code changes

Release Branch Strategy

To maintain clean PRs and avoid sync issues, we create unique release branches for each PR:

  1. Changes are pushed to develop
  2. A new release branch is created (e.g., release/20240101-123456)
  3. PR is created from release branch to main
  4. After merge, develop is synced with main

This ensures:

  • Clean PR history
  • No conflicts during sync
  • Develop stays in sync with main
  • Easy tracking of changes

Automated PR Creation

The PR creation workflow now uses release branches:

name: Create PR from develop to main
on:
  workflow_run:
    workflows: ["Development Deploy"]
    branches: [develop]
    types: [completed]
 
jobs:
  create-pr:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          ref: develop
          token: ${{ secrets.GH_PAT }}
 
      - name: Create PR branch
        run: |
          git config --global user.email "github-actions[bot]@users.noreply.github.com"
          git config --global user.name "github-actions[bot]"
          BRANCH_NAME="release/$(date +%Y%m%d-%H%M%S)"
          git checkout -b $BRANCH_NAME
          git push origin $BRANCH_NAME
          echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
 
      - name: Check Content Changes
        id: check-changes
        uses: actions/github-script@v7
        with:
          script: |
            const contentPaths = [
              'apps/web/src/content/',
              'apps/web/public/images/'
            ];
 
            const { execSync } = require('child_process');
            const diff = execSync('git fetch origin main && git diff origin/main...HEAD --name-only').toString().trim().split('\n');
 
            const isContentOnly = diff.length > 0 && 
              diff.every(file => 
                contentPaths.some(path => file.startsWith(path))
              );
 
            core.setOutput('is-content-only', isContentOnly);
 
      - name: Create Pull Request
        id: create-pr
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GH_PAT }}
          script: |
            const isContentOnly = ${{ steps.check-changes.outputs.is-content-only }};
 
            const { data: prs } = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              head: `${context.repo.owner}:${process.env.BRANCH_NAME}`,
              base: 'main',
              state: 'open'
            });
 
            let prNumber;
            if (prs.length === 0) {
              const { data: pr } = await github.rest.pulls.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title: isContentOnly ? 'content: update blog posts' : 'chore: merge develop into main',
                head: process.env.BRANCH_NAME,
                base: 'main',
                body: isContentOnly 
                  ? 'Automated PR for content changes only. This PR can be auto-merged.\n\nThis PR was automatically created by GitHub Actions.'
                  : 'Automated PR to merge develop into main after successful deployment to development environment.\n\nThis PR requires manual review.'
              });
              
              prNumber = pr.number;
              
              const labels = isContentOnly 
                ? ['auto-approve', 'content-only'] 
                : ['needs-review'];
              
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                labels: labels
              });
            }
 
            core.setOutput('pr-number', prNumber);

Auto-merge and Sync

The auto-merge workflow handles three key tasks:

  1. Automatically merging content-only PRs
  2. Atomically syncing develop with main after merges
  3. Cleaning up temporary release branches
name: Auto Merge PR
on:
  pull_request:
    types: [labeled, closed]
 
jobs:
  auto-merge:
    runs-on: ubuntu-latest
    if: |
      github.event_name == 'pull_request' && (
        (github.event.action == 'labeled' && 
         contains(github.event.pull_request.labels.*.name, 'auto-approve') && 
         contains(github.event.pull_request.labels.*.name, 'content-only')
        ) ||
        (github.event.action == 'closed' && github.event.pull_request.merged == true)
      )
    steps:
      - name: Auto Merge PR
        if: github.event.action == 'labeled'
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GH_PAT }}
          script: |
            await github.rest.pulls.merge({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number,
              merge_method: 'merge'
            });
 
      - name: Get branch name
        if: github.event.pull_request.merged == true
        id: get-branch
        uses: actions/github-script@v7
        with:
          script: |
            const branch = context.payload.pull_request.head.ref;
            core.setOutput('branch', branch);
 
      - name: Sync develop with main
        if: github.event.pull_request.merged == true
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GH_PAT }}
          script: |
            const { data: mainRef } = await github.rest.git.getRef({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: 'heads/main'
            });
 
            await github.rest.git.updateRef({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: 'heads/develop',
              sha: mainRef.object.sha,
              force: true
            });
 
      - name: Delete release branch
        if: github.event.pull_request.merged == true
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GH_PAT }}
          script: |
            const branch = '${{ steps.get-branch.outputs.branch }}';
            if (branch.startsWith('release/')) {
              await github.rest.git.deleteRef({
                owner: context.repo.owner,
                repo: context.repo.repo,
                ref: `heads/${branch}`
              });
            }

This workflow:

  1. Provides immediate auto-merge for content changes
  2. Maintains clean git history by removing temporary branches
  3. Keeps develop in sync with main through atomic updates
  4. Prevents infinite deployment loops
  5. Requires both labels for auto-merge safety

Label Management

For content-only changes, labels are added sequentially to ensure proper triggering:

if (isContentOnly) {
  await github.rest.issues.addLabels({
    owner: context.repo.owner,
    repo: context.repo.repo,
    issue_number: prNumber,
    labels: ['content-only']
  });
 
  await github.rest.issues.addLabels({
    owner: context.repo.owner,
    repo: context.repo.repo,
    issue_number: prNumber,
    labels: ['auto-approve']
  });
}

This ensures:

  1. Both required labels are present
  2. Auto-merge is triggered correctly
  3. Content changes are properly identified

Conclusion

This workflow setup provides several benefits:

  1. Faster content updates by skipping unnecessary builds
  2. Automated PR management with appropriate labeling
  3. Clear separation between content and code changes
  4. Reliable production deployments
  5. Reduced manual intervention for content updates
  6. Clean git history through temporary release branches