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

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 detectioncreate-pr.yml
: Creates release branches and PRs from develop to mainauto-merge.yml
: Manages PR merging, branch cleanup, and develop syncproduction.yml
: Handles production deployments from main
Each workflow has a specific responsibility:
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:
- Ignores markdown file changes
- Skips deployment when commit message contains [skip ci]
- 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:
- Defines content paths (blog posts and images)
- Gets changed files from either push or PR events
- Checks if all changes are within content paths
- 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
2. PR Creation Phase
3. Merge Process
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:
- Changes are pushed to develop
- A new release branch is created (e.g.,
release/20240101-123456
) - PR is created from release branch to main
- 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:
- Automatically merging content-only PRs
- Atomically syncing develop with main after merges
- 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:
- Provides immediate auto-merge for content changes
- Maintains clean git history by removing temporary branches
- Keeps develop in sync with main through atomic updates
- Prevents infinite deployment loops
- 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:
- Both required labels are present
- Auto-merge is triggered correctly
- Content changes are properly identified
Conclusion
This workflow setup provides several benefits:
- Faster content updates by skipping unnecessary builds
- Automated PR management with appropriate labeling
- Clear separation between content and code changes
- Reliable production deployments
- Reduced manual intervention for content updates
- Clean git history through temporary release branches