Tools: Update: GitHub Actions Complete Guide: Build Your First CI/CD Pipeline in 2026

Tools: Update: GitHub Actions Complete Guide: Build Your First CI/CD Pipeline in 2026

What Is GitHub Actions?

Your First Workflow File

Common Workflow Patterns

Node.js / TypeScript CI

Python CI

Docker Build and Push

Secrets and Environment Variables

Built-in Variables

Caching Dependencies

Conditional Steps and Jobs

Deployment Examples

Deploy to Cloudflare Pages

Deploy to Vercel

Deploy to AWS S3

Complete Full-Stack Pipeline

Scheduled Workflows (Cron Jobs)

Debugging Failed Workflows

GitHub Actions vs Alternatives

Key Takeaways

Related Tools on DevPlaybook

Level Up Your Dev Workflow Every professional software team automates the boring parts: running tests on every pull request, building Docker images, deploying to production. GitHub Actions is how most teams do it — built directly into GitHub, free for public repos, and powerful enough for enterprise pipelines. This guide teaches you GitHub Actions from zero. By the end, you will have a working CI/CD pipeline that runs tests, checks code quality, and deploys your app automatically. GitHub Actions is a workflow automation platform built into GitHub. You describe what you want to happen (run tests, build Docker images, deploy to cloud) in YAML files, and GitHub runs those steps on managed servers when you push code or open a pull request. Create .github/workflows/ci.yml in your repo: Push this file to your repo and open the Actions tab — you will see your first workflow run. The matrix strategy runs your tests across multiple Node.js versions in parallel — essential for library authors. Never hardcode API keys in workflow files. Use GitHub Secrets: Reference it in your workflow: GitHub provides useful context variables automatically: Cache expensive operations to speed up workflows: Or use the built-in cache in setup-node: Cache hit rates matter. The hashFiles() function creates a key from your lockfile — so the cache invalidates whenever dependencies change, but reuses the same cache across multiple runs with identical dependencies. Run steps only under certain conditions: Here is a production-ready pipeline that covers the full CI/CD lifecycle: Run workflows on a schedule: Use DevPlaybook's Cron Generator to build cron expressions without memorizing the syntax. When a workflow fails: For most teams using GitHub, Actions is the default choice. The native integration (no tokens needed for repo access, automatic PR status checks) is a significant DX advantage. Found this useful? Explore DevPlaybook — cheat sheets, tool comparisons, and hands-on guides for modern developers. 🛒 Get the DevToolkit Starter Kit on Gumroad — 40+ browser-based dev tools, source code + deployment guide included. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

name: CI on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Run linter run: npm run lint name: CI on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Run linter run: npm run lint name: CI on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Run linter run: npm run lint name: Node CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [18, 20, 22] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "npm" - run: npm ci - run: npm run build --if-present - run: npm test name: Node CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [18, 20, 22] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "npm" - run: npm ci - run: npm run build --if-present - run: npm test name: Node CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [18, 20, 22] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "npm" - run: npm ci - run: npm run build --if-present - run: npm test name: Python CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install pytest pytest-cov - name: Run tests with coverage run: pytest --cov=. --cov-report=xml - name: Upload coverage uses: codecov/codecov-action@v4 name: Python CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install pytest pytest-cov - name: Run tests with coverage run: pytest --cov=. --cov-report=xml - name: Upload coverage uses: codecov/codecov-action@v4 name: Python CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install pytest pytest-cov - name: Run tests with coverage run: pytest --cov=. --cov-report=xml - name: Upload coverage uses: codecov/codecov-action@v4 name: Docker on: push: branches: [main] tags: ["v*.*.*"] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=semver,pattern={{version}} type=sha,prefix=sha- - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max name: Docker on: push: branches: [main] tags: ["v*.*.*"] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=semver,pattern={{version}} type=sha,prefix=sha- - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max name: Docker on: push: branches: [main] tags: ["v*.*.*"] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=semver,pattern={{version}} type=sha,prefix=sha- - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max steps: - name: Deploy to production env: API_KEY: ${{ secrets.DEPLOY_API_KEY }} NODE_ENV: production run: | curl -X POST https://your-api.com/deploy \ -H "Authorization: Bearer $API_KEY" steps: - name: Deploy to production env: API_KEY: ${{ secrets.DEPLOY_API_KEY }} NODE_ENV: production run: | curl -X POST https://your-api.com/deploy \ -H "Authorization: Bearer $API_KEY" steps: - name: Deploy to production env: API_KEY: ${{ secrets.DEPLOY_API_KEY }} NODE_ENV: production run: | curl -X POST https://your-api.com/deploy \ -H "Authorization: Bearer $API_KEY" - name: Cache node_modules uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - run: npm ci - name: Cache node_modules uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - run: npm ci - name: Cache node_modules uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - run: npm ci - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" # automatically caches ~/.npm - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" # automatically caches ~/.npm - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" # automatically caches ~/.npm # Only run on main branch - name: Deploy to production if: github.ref == 'refs/heads/main' run: ./deploy.sh # Skip on draft PRs - name: Run integration tests if: github.event.pull_request.draft == false run: npm run test:integration # Run only if previous step failed - name: Notify on failure if: failure() uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} # Only run on main branch - name: Deploy to production if: github.ref == 'refs/heads/main' run: ./deploy.sh # Skip on draft PRs - name: Run integration tests if: github.event.pull_request.draft == false run: npm run test:integration # Run only if previous step failed - name: Notify on failure if: failure() uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} # Only run on main branch - name: Deploy to production if: github.ref == 'refs/heads/main' run: ./deploy.sh # Skip on draft PRs - name: Run integration tests if: github.event.pull_request.draft == false run: npm run test:integration # Run only if previous step failed - name: Notify on failure if: failure() uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 with: apiToken: ${{ secrets.CF_API_TOKEN }} accountId: ${{ secrets.CF_ACCOUNT_ID }} projectName: my-project directory: dist gitHubToken: ${{ secrets.GITHUB_TOKEN }} - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 with: apiToken: ${{ secrets.CF_API_TOKEN }} accountId: ${{ secrets.CF_ACCOUNT_ID }} projectName: my-project directory: dist gitHubToken: ${{ secrets.GITHUB_TOKEN }} - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 with: apiToken: ${{ secrets.CF_API_TOKEN }} accountId: ${{ secrets.CF_ACCOUNT_ID }} projectName: my-project directory: dist gitHubToken: ${{ secrets.GITHUB_TOKEN }} - name: Deploy to Vercel uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.ORG_ID }} vercel-project-id: ${{ secrets.PROJECT_ID }} vercel-args: "--prod" - name: Deploy to Vercel uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.ORG_ID }} vercel-project-id: ${{ secrets.PROJECT_ID }} vercel-args: "--prod" - name: Deploy to Vercel uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.ORG_ID }} vercel-project-id: ${{ secrets.PROJECT_ID }} vercel-args: "--prod" - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - name: Deploy static site to S3 run: aws s3 sync ./dist s3://my-bucket --delete - name: Invalidate CloudFront run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DIST_ID }} --paths "/*" - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - name: Deploy static site to S3 run: aws s3 sync ./dist s3://my-bucket --delete - name: Invalidate CloudFront run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DIST_ID }} --paths "/*" - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - name: Deploy static site to S3 run: aws s3 sync ./dist s3://my-bucket --delete - name: Invalidate CloudFront run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DIST_ID }} --paths "/*" name: Full CI/CD Pipeline on: push: branches: [main] pull_request: branches: [main] jobs: # ─── Stage 1: Quality Gates ─────────────────────────────────── quality: name: Code Quality runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - run: npm ci - run: npm run type-check # TypeScript check - run: npm run lint # ESLint - run: npm run format:check # Prettier # ─── Stage 2: Tests ─────────────────────────────────────────── test: name: Tests runs-on: ubuntu-latest needs: quality # Only runs if quality passes steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - run: npm ci - run: npm test -- --coverage - name: Upload coverage report uses: codecov/codecov-action@v4 # ─── Stage 3: Build ─────────────────────────────────────────── build: name: Build runs-on: ubuntu-latest needs: test # Only runs if tests pass steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - run: npm ci - run: npm run build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/ retention-days: 1 # ─── Stage 4: Deploy (main branch only) ─────────────────────── deploy: name: Deploy to Production runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/main' && github.event_name == 'push' environment: name: production url: https://your-site.com steps: - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Deploy env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} run: | echo "Deploying to production..." # your deploy command here name: Full CI/CD Pipeline on: push: branches: [main] pull_request: branches: [main] jobs: # ─── Stage 1: Quality Gates ─────────────────────────────────── quality: name: Code Quality runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - run: npm ci - run: npm run type-check # TypeScript check - run: npm run lint # ESLint - run: npm run format:check # Prettier # ─── Stage 2: Tests ─────────────────────────────────────────── test: name: Tests runs-on: ubuntu-latest needs: quality # Only runs if quality passes steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - run: npm ci - run: npm test -- --coverage - name: Upload coverage report uses: codecov/codecov-action@v4 # ─── Stage 3: Build ─────────────────────────────────────────── build: name: Build runs-on: ubuntu-latest needs: test # Only runs if tests pass steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - run: npm ci - run: npm run build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/ retention-days: 1 # ─── Stage 4: Deploy (main branch only) ─────────────────────── deploy: name: Deploy to Production runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/main' && github.event_name == 'push' environment: name: production url: https://your-site.com steps: - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Deploy env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} run: | echo "Deploying to production..." # your deploy command here name: Full CI/CD Pipeline on: push: branches: [main] pull_request: branches: [main] jobs: # ─── Stage 1: Quality Gates ─────────────────────────────────── quality: name: Code Quality runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - run: npm ci - run: npm run type-check # TypeScript check - run: npm run lint # ESLint - run: npm run format:check # Prettier # ─── Stage 2: Tests ─────────────────────────────────────────── test: name: Tests runs-on: ubuntu-latest needs: quality # Only runs if quality passes steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - run: npm ci - run: npm test -- --coverage - name: Upload coverage report uses: codecov/codecov-action@v4 # ─── Stage 3: Build ─────────────────────────────────────────── build: name: Build runs-on: ubuntu-latest needs: test # Only runs if tests pass steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - run: npm ci - run: npm run build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/ retention-days: 1 # ─── Stage 4: Deploy (main branch only) ─────────────────────── deploy: name: Deploy to Production runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/main' && github.event_name == 'push' environment: name: production url: https://your-site.com steps: - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Deploy env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} run: | echo "Deploying to production..." # your deploy command here on: schedule: - cron: "0 9 * * 1" # Every Monday at 9 AM UTC on: schedule: - cron: "0 9 * * 1" # Every Monday at 9 AM UTC on: schedule: - cron: "0 9 * * 1" # Every Monday at 9 AM UTC brew install act act push # simulates a push event locally brew install act act push # simulates a push event locally brew install act act push # simulates a push event locally - Triggers on every push to main or develop, and on pull requests targeting main - Checks out your code - Installs Node.js 20 with npm caching (speeds up subsequent runs) - Installs dependencies with npm ci (faster than npm install, uses lockfile exactly) - Runs your test suite - Runs your linter - Builds your Docker image on pushes to main - Uses GitHub's built-in Container Registry (free storage for public repos) - Automatically tags images with branch name, semver version, and commit SHA - Uses GitHub Actions cache to speed up Docker layer builds - Go to your repo → Settings → Secrets and variables → Actions - Click New repository secret - Add your secret (e.g., DEPLOY_API_KEY) - Runs quality checks first (fastest, cheapest to fail early) - Runs tests only if quality passes (no wasted minutes) - Builds only if tests pass - Deploys only on main branch pushes, not PRs - Uses environments for deployment protection rules (require manual approval) - Read the red X — click the failed step to see the full output - Check exit codes — non-zero = failure. echo $? after commands helps - Add debug logging — set the ACTIONS_STEP_DEBUG secret to true for verbose runner output - Run locally — use act to run GitHub Actions locally: - Create workflows in .github/workflows/*.yml - Use on: to define triggers (push, pull_request, schedule) - Chain jobs with needs: to create pipeline stages - Store secrets in GitHub Settings, never in code - Cache dependencies with actions/cache@v4 or via setup-* actions - Use if: github.ref == 'refs/heads/main' to gate deployments to specific branches - Run locally with act to debug without burning CI minutes - Cron Expression Generator — build schedule: cron values visually - JSON Formatter — validate workflow output - Git Command Generator — generate git commands for your scripts - Regex Tester — test patterns used in if: conditions