GitHub Actions gives you free compute (2,000 minutes/month on public repos, 500 on private) that can build and publish YouTube videos. Your video project lives in a repo, and pushes trigger the pipeline. It is CI/CD for content, using tools you already know.
Repository Structure
youtube-channel/
.github/
workflows/
render.yml
publish.yml
videos/
2026-03-28-react-hooks/
script.md
metadata.yml
assets/
recording.mp4 (git-lfs)
templates/
intro.mp4
outro.mp4
thumbnail.psd
scripts/
render.js
upload.js
validate.js
Each video gets its own directory with a script, metadata, and assets. The render and upload logic lives in shared scripts.
The Render Workflow
name: Render Video
on:
push:
paths: ['videos/**']
workflow_dispatch:
inputs:
video_dir:
description: 'Video directory to render'
required: true
jobs:
render:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
npm ci
- name: Determine video directory
id: detect
run: |
if [ -n "${{ github.event.inputs.video_dir }}" ]; then
echo "dir=${{ github.event.inputs.video_dir }}" >> $GITHUB_OUTPUT
else
echo "dir=$(git diff --name-only HEAD~1 | grep '^videos/' | head -1 | cut -d/ -f1-2)" >> $GITHUB_OUTPUT
fi
- name: Render
run: node scripts/render.js "${{ steps.detect.outputs.dir }}"
- name: Validate output
run: node scripts/validate.js output/
- uses: actions/upload-artifact@v4
with:
name: rendered-video
path: output/
retention-days: 7
The Publish Workflow
Separate rendering from publishing. The publish workflow can be triggered manually after review or automatically on a schedule:
name: Publish Video
on:
workflow_dispatch:
inputs:
artifact_name:
description: 'Artifact from render workflow'
required: true
schedule:
- cron: '0 14 * * 1-5' # Weekdays at 2 PM UTC
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: ${{ github.event.inputs.artifact_name || 'latest-render' }}
path: output/
- name: Upload to YouTube
env:
YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }}
YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }}
YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }}
run: node scripts/upload.js output/
Storing Secrets
Your YouTube OAuth credentials go in GitHub repository secrets. Never commit them to the repo. The pipeline accesses them as environment variables during the upload step. You need three secrets: the OAuth client ID, client secret, and a refresh token that you generate once via the consent flow.
Limitations and Workarounds
- 6-hour job timeout: More than enough for most renders, but extremely long videos may need to be split
- 14GB storage limit per artifact: Rendered 1080p videos are usually 500MB-2GB, so this is fine for typical content
- No GPU: GitHub Actions runners are CPU-only. Use libx264 software encoding. Hardware encoding is not available
- git-lfs bandwidth: Large video assets count against your LFS bandwidth quota. Consider using S3/R2 presigned URLs instead of LFS for source recordings
When This Pipeline Shines
This setup is ideal for developer content channels where the videos are tightly coupled to code. Tutorial channels, DevRel teams, open-source project documentation -- anywhere the video content lives naturally alongside code. The PR-based review workflow lets team members review scripts before rendering and review renders before publishing, with full audit trails in git history.