Skip to content

Transitioning GitHub Pages to Deploy from Artifacts Instead of a Branch

For a long time, publishing this site was simple in the best possible way. ProperDocs built the HTML, Materialx handled the presentation, and a GitHub Actions workflow ran properdocs gh-deploy through task deploy. That command force-pushed the rendered site to a gh-pages branch, then GitHub's Pages backend quietly noticed the branch update and published it.

That model still worked, but the warning lights started blinking in the generated pages build and deployment job. GitHub was warning about Node.js 20 deprecation inside the Pages deployment path.

The noisy warning was only the symptom. The deeper problem was architectural: the deployment was still using a Git branch as an artifact transport.

The fix was to stop publishing a branch at all. The GitHub Action now builds the content, uploads the contents as GitHub Pages artifacts, and then actions/deploy-pages publishes it through GitHub's modern Pages deployment path.

The Previous Workflow

The previous workflow had one job: Install the dependencies and then run the deploy task.

Underneath that task, properdocs gh-deploy built the site and force-pushed the output to the gh-pages branch using Task.

---
name: Deploy to GitHub Pages

on:
  push:
    branches:
      - main
    paths:
      - '.github/workflows/deploy.yml'
      - '.github/workflows/requirements.txt'
      - '.overrides/**'
      - 'docs/**'
      - 'snippets/**'
      - 'mkdocs.yml'
  workflow_dispatch:

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
      - name: Setup Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: 3.x
      - name: Setup Task
        uses: tenthirtyam/setup-task@09f14d66a9c5c0e995461896dd5cb6e1d74df08b # v1.0.4
        with:
          version: latest
      - name: Install Dependencies
        run: task install
      - name: Deploy to GitHub Pages
        run: task deploy

There are a few tells in that file.

First, the deploy job needs contents: write because the workflow writes back to the repository. Not to source code, but to a generated branch. That's still repository write access.

Second, the job mixes build and release responsibilities. If dependency installation fails, site generation fails, or the branch push fails, the whole workflow fails in one place. That's workable, but it gives you fewer clean boundaries when troubleshooting.

Lastly, the repository keeps a gh-pages branch full of generated files. That branch doesn't help me review content. It exists because the old Pages deployment model needed a place to watch.

The site builds weren't failing, and the GitHub Action was still working. That's what made the problem annoying. The failure mode wasn't "your build is broken." It was "the platform path you're using is aging under your feet."

The pages build and deployment workflow jobs started to report the following:

Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work
as expected. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026.
Node.js 20 will be removed from the runner on September 16th, 2026.

This wasn't coming from my site code, and it wasn't something I could fix by changing the ProperDocs build. It was coming from the deployment machinery around GitHub Pages when Pages deploys from a branch.

For me, the conclusion was simple: if GitHub Pages has a first-class Actions deployment path, use that path.

The New Workflow

The updated workflow separates the pipeline into two jobs:

  • build checks out the repository, installs dependencies, builds the site, and uploads .site as a Pages artifact.
  • deploy waits for build, requests the Pages and OIDC permissions it needs, and publishes the artifact with actions/deploy-pages.
---
name: Deploy to GitHub Pages

on:
  push:
    branches:
      - main
    paths:
      - '.github/workflows/deploy.yml'
      - '.github/workflows/requirements.txt'
      - '.overrides/**'
      - 'docs/**'
      - 'snippets/**'
      - 'properdocs.yml'
      - 'Taskfile.yml'
  workflow_dispatch:

concurrency:
  group: pages
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
      - name: Setup Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: 3.x
          cache: pip
          cache-dependency-path: |
            .github/workflows/requirements.txt
            Taskfile.yml
      - name: Setup Task
        uses: tenthirtyam/setup-task@d179f8003b3fabb68608fd63d218495faa40f101 # v1.0.5
        with:
          version: latest
      - name: Install Dependencies
        run: task install
      - name: Run Build
        run: task build
      - name: Upload Artifacts
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
        with:
          path: .site

  deploy:
    runs-on: ubuntu-latest
    needs: build
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

The source of truth changes in the repository settings too. GitHub Pages should be configured to deploy from GitHub Actions, not from a branch. Once that setting is changed, the gh-pages branch is no longer part of the publishing path.

What Changed

The biggest change is that the workflow no longer writes generated output back into Git. The repository contains source. The workflow produces an artifact. GitHub Pages deploys the artifact. That's the right separation of concerns.

Area Branch-based Deploy Artifact-based Deploy
Publish Target gh-pages branch Pages artifact
Job Shape One combined deploy job Separate build and deploy jobs
Permissions contents: write pages: write and id-token: write
Generated Output Committed to a branch Stored as a workflow artifact
Troubleshooting Mixed build, Git, and Pages behavior Build failure and deploy failure are separate
Repository History Includes automated branch commits Source history stays clean

The permission model is better. The default workflow permission remains contents: read, and only the deploy job gets pages: write plus id-token: write. That OIDC permission isn't decoration. actions/deploy-pages uses it to request a deployment token for GitHub Pages. If you omit it, the deploy job doesn't have the identity channel it needs.

The caching story is also cleaner. actions/setup-python now caches pip dependencies using .github/workflows/requirements.txt and Taskfile.yml as dependency inputs. That keeps repeated builds faster without turning the workflow into a hand-rolled cache puzzle. Use the boring cache when boring is the point.

The concurrency block is small but useful:

concurrency:
  group: pages
  cancel-in-progress: false

Pages deployments are production deployments. I don't want two publishes racing each other, but I also don't want a newer push to cancel a deployment halfway through unless I've made that decision on purpose. This keeps deployment ordering explicit.

The migration let me remove a few pieces of old ceremony, too:

  • No contents: write permission for generated output.
  • No forced push to gh-pages.
  • No generated branch as a deployment substrate.
  • No task deploy wrapper around properdocs gh-deploy.

The local workflow is simpler now.

Build the site into .site:

task build

The CI workflow uploads that directory:

- name: Upload Pages Artifact
  uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
  with:
    path: .site

And then GitHub Pages deploys it:

- name: Deploy to GitHub Pages
  id: deployment
  uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

That's the whole contract.

Build static files. Upload static files. Deploy static files.

Migration Checklist

If you're moving a ProperDocs, MkDocs, or similar static site off gh-pages, this is the checklist I suggest:

  1. Change the GitHub Pages source to GitHub Actions in the repository Pages settings.
  2. Replace properdocs gh-deploy, mkdocs gh-deploy, or similar branch-push commands with a normal build command.
  3. Make sure the build writes to the directory you upload, such as .site.
  4. Add actions/upload-pages-artifact to the build job.
  5. Add a separate deploy job using actions/deploy-pages.
  6. Grant the deploy job pages: write and id-token: write.
  7. Keep top-level workflow permissions at contents: read.
  8. Add dependency caching where the setup action supports it.
  9. Leave the old gh-pages branch alone until the new deployment has succeeded at least once.

Once the Actions-based deployment is healthy and Pages settings point at GitHub Actions, the branch can be archived or deleted according to your repository policy.

Branch-based GitHub Pages deployment was a clever bridge for a time. But artifact-based deployment is a cleaner model that gives the build system a build artifact, GitHub Pages a deployment artifact, and keeps Git focused on authored source. When the old path starts throwing platform deprecation warnings from behind the curtain, that's not a mystery to romanticize. It's the platform telling you where the maintained road is.

References