GitHub Actions & GitHub Pages: A Practical Deep Dive
GitHub Actions & GitHub Pages: A Practical Deep Dive
How GitHub Pages Deployment Actually Works (Jekyll + GitHub Actions)
When you use Jekyll with GitHub Actions to build and deploy a site, the generated HTML is never committed back to any branch in your repository. This surprises many developers who expect to find a gh-pages branch or a _site folder in their repo.
The full pipeline looks like this:
+--------------------------------------------------+
| Your Repository (main branch) |
| - README.md, *.md source files |
| - _config.yml, assets, layouts |
+--------------------+-----------------------------+
|
| push triggers GitHub Actions
v
+--------------------------------------------------+
| GitHub Actions (CI/CD pipeline) |
| - Jekyll converts Markdown -> HTML |
| - Output lands in ./_site (ephemeral) |
| - Packaged as a Pages artifact |
+--------------------+-----------------------------+
|
| deploy-pages action
v
+--------------------------------------------------+
| GitHub Pages Hosting Infrastructure (CDN) |
| - Stores final HTML/CSS/JS |
| - Served via *.github.io |
| - Physical location undisclosed by GitHub |
+--------------------------------------------------+
Workflow Example
# .github/workflows/jekyll-gh-pages.yml
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: ./
destination: ./_site
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
- name: Deploy to GitHub Pages
uses: actions/deploy-pages@v4
The HTML only ever exists in two transient places: the runner’s _site directory during the build, and GitHub’s internal Pages CDN after deployment. If you want to inspect the output, download the artifact from the Actions tab of a successful run.
Personal vs. Organization Repositories
GitHub Pages behaves identically for both. The only difference is the URL structure:
| Repository Type | URL |
|---|---|
User / Org site (<owner>.github.io) |
https://<owner>.github.io |
| Project site (any repo name) | https://<owner>.github.io/<repo> |
Resource Limits to Know
| Limit | Value |
|---|---|
| Published site size | 1 GB |
| Bandwidth (soft) | 100 GB / month |
| Build frequency (soft) | 10 builds / hour (custom Actions workflows are exempt) |
The “Pages build and deployment” Workflow You Did Not Create
If you see a workflow called Pages build and deployment running automatically without you defining it, here is why: as long as Settings > Pages > Source is set to Deploy from a branch, GitHub silently injects this system workflow on every push.
Fix: Go to Settings > Pages and switch Source to GitHub Actions. The system workflow disappears and your custom workflow takes full control.
Self-Hosted Runners: How They Actually Work
A self-hosted runner is a long-lived process on your local machine that long-polls GitHub for jobs. It is not a webhook receiver — it reaches out to GitHub, not the other way around.
The Mechanics
- Long polling over HTTPS — After startup, the runner opens a persistent HTTPS connection to GitHub and waits for job assignments. This is an efficient server-push pattern, not a rapid short-poll loop.
- Job execution — When a job arrives, the runner fetches the workflow definition, clones the repository, executes each step, and streams logs back to GitHub in real time.
- Idle waiting — When no jobs are queued, the process stays alive and keeps its connection open.
Why Jobs Get Stuck in “Queued”
If a workflow uses runs-on: self-hosted and the job never starts, the runner process is almost certainly not running or has lost its connection. Check with:
ps aux | grep Runner.Worker
Managing the Runner as a System Service
Running ./run.sh directly keeps the runner alive only in your terminal session. For production use, install it as a service:
# Install and start as a background service
sudo ./svc.sh install
sudo ./svc.sh start
# Lifecycle management
sudo ./svc.sh status
sudo ./svc.sh stop
sudo ./svc.sh restart
# Remove the service entirely
sudo ./svc.sh uninstall
To check which user account the service runs under:
systemctl show actions.runner.* -p User
Alternative: Use GitHub-Hosted Runners
If you do not need local hardware, swap runs-on: self-hosted for runs-on: ubuntu-latest (or windows-latest / macos-latest). GitHub provisions and tears down the VM automatically — no local process to maintain.
Fixing the EACCES Permission Error with actions/jekyll-build-pages
actions/jekyll-build-pages@v1 runs Jekyll inside a Docker container as root. Files written to _site end up owned by root, which causes the subsequent upload-pages-artifact step to fail with EACCES when it tries to clean up temp files.
Fix: Add a step immediately after the Jekyll build to correct ownership:
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: ./
destination: ./_site
- name: Fix _site ownership
run: docker run --rm -v "$":/workspace alpine chown -R $(id -u):$(id -g) /workspace/_site
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
This spins up a minimal Alpine container, remounts the workspace, and chowns the _site directory to the runner UID/GID before the upload step runs.