Explainer

Validate Every Change, Deploy Only from main: Jekyll + GitHub Pages

How a historical Jekyll site used GitHub Actions to validate every branch and pull request while reserving production publishing for changes merged to main.

On this page

This blog was once built with Jekyll and served by GitHub Pages. The main branch held the Markdown, configuration, and tooling that produced the site. GitHub Pages served the generated files from a separate branch. When every edit went straight to main, one build-and-publish path seemed enough.

That changed when I started reviewing article and layout changes through feature branches and pull requests. I wanted an early signal that a proposed change still built, but I did not want an unfinished branch to replace the public site. The goal was simple: validate every change, publish only after a change reaches main.

The rule: validate often, deploy deliberately

Feature branches and pull requests should build and check the site without publishing it. A merge to main should be the deliberate production boundary. A green check means “this source passed its checks,” not “the public site has changed.”

What main and gh-pages each contain

main held the source: Markdown, Jekyll configuration, scripts, and workflow definitions. gh-pages held the generated HTML, CSS, images, and other files that GitHub Pages served. One branch described what to build; the other held the build output. The output could be regenerated, so it was not a second place to edit the site.

The architecture

Feature branch / pull request

  1. Push or open/update PRAny branch; validation only
  2. Validate blogJekyll build + language-route checks
  3. Check resultNo publication to gh-pages

Merge to main

  1. Automatic buildProduction Jekyll build + route check
  2. Generated filesPublished to gh-pages
  3. GitHub PagesServes the generated site
Validation and publication are separate paths: a green check does not deploy, and production publication is gated by main.

Why separate CI from CD

CI answers, “Can this change build correctly?” It can run for every branch and pull request because it does not change the public site. CD answers, “Should this verified output replace production?” Its trigger should be narrower. In this design, publication waited until a change had been reviewed and merged to main.

The two workflows at a glance

The historical implementation gave those two paths specific names:

  • Validate blog (.github/workflows/main.yml) — Runs for any branch push, a pull request, or manual workflow_dispatch. It builds with Jekyll and checks language routes; it never deploys.
  • Automatic build (.github/workflows/pages-deploy.yml) — Runs for a main push except changes limited to ignored paths, or a manual workflow_dispatch. Its job guard allows deployment only when the ref is refs/heads/main.

For Automatic build, the ignored paths are .gitignore, README.md, LICENSE, and ARTICLE_PROGRESS.md. A change limited to those files does not trigger its push event. A push to main otherwise runs the validation workflow as well as the publication workflow.

Validate blog: check without publishing

Validate blog runs for pushes to every branch, pull requests, and manual dispatches. It sets up Ruby 2.7 with Bundler caching, builds the Jekyll site, then verifies the language routes generated in _site:

bundle exec jekyll build
ruby tools/verify_language_routes.rb _site

The workflow is deliberately check-only. A successful result on a feature branch or pull request says that its source passes the build and route check; it does not alter gh-pages or publish a preview to production.

Automatic build: publish from main

The production workflow’s push trigger is restricted to main. It also offers workflow_dispatch, but the job guard allows the deployment job to proceed only for refs/heads/main; selecting a different branch does not authorize a release. The workflow sets up Ruby 2.7 and Bundler, then invokes:

bash tools/deploy.sh

That helper makes a production Jekyll build, checks the language routes, replaces the generated content on gh-pages, and historically published it with a force push. The branch is generated output, not a second place to maintain the site: do not edit gh-pages by hand. Changes belong in source on main or a feature branch.

Following one edit from branch to website

Suppose a contributor notices a typo in a sentence in a Korean article. They create a feature branch from main, fix the Markdown sentence, commit, and push the branch. Validate blog runs automatically; its Jekyll build and language-route check provide feedback, but the public site stays unchanged.

The contributor opens a pull request into main. Validation runs for the pull request too, so reviewers can see the check result alongside the source change. Once the pull request is approved and merged, the change is on main. The merge push triggers Automatic build (assuming the changed paths are not limited to its ignored list). The deployment helper performs its production build and route check, replaces the generated gh-pages content, and publishes it. After the workflow completes, the contributor can open the Korean article on the public GitHub Pages site and confirm the corrected sentence is live.

Previewing the former Jekyll site locally

For a local preview of the former Jekyll setup, I used:

bundle install
bundle exec jekyll serve

For a production-only rendering issue in that historical setup, I ran:

JEKYLL_ENV=production bundle exec jekyll build

Historical context

This describes the repository’s former Jekyll and GitHub Pages workflow. Production migrated to a Node/Astro static build on 2026-10-06: main now builds site-v2/dist, which is automatically published to gh-pages and served by GitHub Pages. The Jekyll workflow above is historical, not the current production path.

Validate often, deploy deliberately.