Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • ๐Ÿ”ฎ Predictions
  • ๐Ÿ“ฐ Breaking News
  • ๐ŸŽจ AI Art
  • ๐Ÿ“– Short Stories
  • View All โ†’
  • Products โ†’

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

ยฉ 2021-2026 Crashbytesยฎ by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Tutorial: GitHub Actions CI/CD Complete Guide - Workflow Automation from Zero to Production
tutorialNovember 24, 202522 min readโ€ข By Michael Eakins

Tutorial: GitHub Actions CI/CD Complete Guide - Workflow Automation from Zero to Production

Master GitHub Actions from scratch with hands-on examples. Build complete CI/CD pipelines with testing, linting, deployment, and advanced workflow patterns for modern development teams.

Quick Takeaways

What you'll learn in this article

22 min read
Intermediate
  • 1

    Basic GitHub Actions workflow understanding triggers, jobs, and steps

  • 2

    Continuous Integration pipeline with testing and linting

  • 3

    Multi-environment deployments to staging and production

  • 4

    Docker image builds with registry pushes

  • 5

    Matrix testing strategies across Node versions and operating systems

Keep reading for detailed implementation, code examples, and real-world results

After configuring GitHub Actions pipelines for dozens of production applications, I've learned that the difference between a functioning workflow and a truly effective CI/CD pipeline lies in understanding the core concepts, writing efficient jobs, and implementing patterns that scale with your team.

This tutorial takes you from zero GitHub Actions knowledge to building production-ready CI/CD pipelines. We'll cover everything from basic workflows to advanced patterns including matrix builds, caching strategies, deployment automation, and security best practices.

Tutorial Overview

What You'll Build

  • Basic GitHub Actions workflow understanding triggers, jobs, and steps
  • Continuous Integration pipeline with testing and linting
  • Multi-environment deployments to staging and production
  • Docker image builds with registry pushes
  • Matrix testing strategies across Node versions and operating systems
  • Caching and optimization for faster builds
  • Secrets management and security patterns
  • Reusable workflows for DRY pipeline configuration

Repository Structure

All code for this tutorial is available at github.com/CrashBytes/ByteSizedExamples/tree/main/crashbytes-tutorial-github-actions-ci-cd.

crashbytes-tutorial-github-actions-ci-cd/
โ”œโ”€โ”€ .github/
โ”‚   โ””โ”€โ”€ workflows/
โ”‚       โ”œโ”€โ”€ ci.yml                    # Basic CI workflow
โ”‚       โ”œโ”€โ”€ cd-staging.yml            # Staging deployment
โ”‚       โ”œโ”€โ”€ cd-production.yml         # Production deployment
โ”‚       โ”œโ”€โ”€ docker-build.yml          # Docker image builds
โ”‚       โ”œโ”€โ”€ matrix-testing.yml        # Matrix test strategy
โ”‚       โ””โ”€โ”€ reusable-workflow.yml     # Reusable workflow template
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ index.js                      # Application entry point
โ”‚   โ”œโ”€โ”€ utils.js                      # Utility functions
โ”‚   โ””โ”€โ”€ api.js                        # API handlers
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ index.test.js                 # Unit tests
โ”‚   โ””โ”€โ”€ integration.test.js           # Integration tests
โ”œโ”€โ”€ Dockerfile                        # Container definition
โ”œโ”€โ”€ package.json                      # Dependencies and scripts
โ””โ”€โ”€ README.md                         # Project documentation

Understanding GitHub Actions Fundamentals

Before diving into workflows, let's establish the core concepts that GitHub Actions is built upon. Understanding these fundamentals will make everything else click into place.

Core Components

GitHub Actions operates on a hierarchy of components that work together to automate your software development lifecycle.

Workflows are the top-level automation definitions. Each workflow is a YAML file stored in the .github/workflows/ directory of your repository. A workflow contains one or more jobs and defines when the automation should run.

Events are triggers that start workflow execution. These can be repository events (push, pull request), scheduled times (cron), manual triggers, or external webhooks. Understanding events is crucial because they determine when your automation activates.

Jobs are collections of steps that run on a fresh virtual machine (runner). Jobs run in parallel by default, though you can configure dependencies between them. Each job starts with a clean environment, which ensures consistency but requires explicit artifact passing between jobs.

Steps are individual tasks within a job. Steps can run shell commands, use pre-built actions from the marketplace, or execute custom scripts. Steps within a job share the same runner and filesystem.

Actions are reusable units of code that perform specific tasks. You can use actions from the GitHub Marketplace, create your own, or use composite actions that combine multiple steps.

Runners are the servers that execute your workflows. GitHub provides hosted runners (Ubuntu, Windows, macOS) or you can configure self-hosted runners for specific requirements.

Workflow Syntax Deep Dive

Every workflow file follows the same basic structure. Let's examine each section:

name: Workflow Display Name

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'

jobs:
  job-name:
    runs-on: ubuntu-latest
    steps:
      - name: Step description
        run: echo "Hello, GitHub Actions!"

The name field appears in the Actions tab of your repository. Choose descriptive names that indicate what the workflow accomplishes.

The on section defines trigger events. This is where you specify when GitHub should execute your workflow.

The env section sets environment variables available to all jobs and steps in the workflow.

The jobs section contains the actual work. Each job has a unique identifier and configuration.

Advertisement

Building Your First Workflow

Let's create a complete CI workflow that runs on every push and pull request. This workflow will install dependencies, run linting, execute tests, and generate a coverage report.

Basic CI Workflow

Create the file .github/workflows/ci.yml:

name: Continuous Integration

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'
  CI: true

jobs:
  lint:
    name: Code Linting
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: lint

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests with coverage
        run: npm test -- --coverage

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7

  build:
    name: Build Application
    runs-on: ubuntu-latest
    needs: [lint, test]

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build application
        run: npm run build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 7

Let's break down the key concepts in this workflow:

Job Dependencies: The needs keyword creates dependencies between jobs. The test job waits for lint to complete, and build waits for both lint and test. This ensures code quality checks pass before attempting builds.

Checkout Action: actions/checkout@v4 clones your repository into the runner. Without this step, your code isn't available for subsequent steps.

Node Setup Action: actions/setup-node@v4 installs Node.js and optionally caches dependencies. The cache: 'npm' option automatically caches the npm cache directory based on your package-lock.json hash.

npm ci vs npm install: We use npm ci (clean install) instead of npm install because it's faster, more reliable for CI, and ensures exact versions from package-lock.json.

Artifacts: actions/upload-artifact@v4 preserves files between jobs or for later download. Artifacts are useful for coverage reports, build outputs, and debugging.

Trigger Events Deep Dive

Understanding trigger events gives you precise control over when workflows execute. Let's explore the most useful event configurations.

Push and Pull Request Events

The most common triggers respond to code changes:

on:
  push:
    branches:
      - main
      - develop
      - 'release/**'
    paths:
      - 'src/**'
      - 'package.json'
    paths-ignore:
      - '**.md'
      - 'docs/**'
    tags:
      - 'v*'

  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

Branch filtering lets you restrict workflow execution to specific branches. Glob patterns like 'release/**' match any branch starting with release/.

Path filtering with paths runs workflows only when specified files change. This prevents unnecessary CI runs when only documentation changes. The paths-ignore option excludes specific paths.

Tag triggers enable release automation. The pattern 'v*' matches tags like v1.0.0 or v2.1.3.

Pull request types control which PR events trigger workflows. The default includes opened, synchronize (new commits), and reopened.

Scheduled and Manual Triggers

For maintenance tasks, use scheduled or manual triggers:

on:
  schedule:
    - cron: '0 2 * * 1' # Every Monday at 2 AM UTC
    - cron: '0 6 * * *' # Daily at 6 AM UTC

  workflow_dispatch:
    inputs:
      environment:
        description: 'Deployment environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production
      dry_run:
        description: 'Perform dry run without deployment'
        required: false
        default: false
        type: boolean

Cron schedules use standard cron syntax with five fields: minute, hour, day of month, month, day of week. All times are UTC.

Manual triggers with workflow_dispatch add a "Run workflow" button in the Actions tab. Input parameters let you customize each run.

Workflow Call Triggers

For reusable workflows called by other workflows:

on:
  workflow_call:
    inputs:
      node_version:
        required: true
        type: string
    secrets:
      npm_token:
        required: false

This pattern enables DRY workflow configurations across multiple repositories.

Advanced Workflow Patterns

Now let's implement patterns that handle real-world complexity.

Matrix Testing Strategy

Matrix builds run the same job across multiple configurations simultaneously:

name: Matrix Testing

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test-matrix:
    name: Test on Node ${{ matrix.node }} / ${{ matrix.os }}
    runs-on: ${{ matrix.os }}

    strategy:
      fail-fast: false
      matrix:
        node: [18, 20, 22]
        os: [ubuntu-latest, windows-latest, macos-latest]
        exclude:
          - node: 18
            os: macos-latest
        include:
          - node: 20
            os: ubuntu-latest
            coverage: true

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run coverage
        if: matrix.coverage
        run: npm test -- --coverage

Matrix configuration creates a job for each combination. With 3 Node versions and 3 operating systems, this creates 9 parallel jobs (minus exclusions).

Fail-fast disabled means all matrix jobs run to completion even if one fails. This helps identify which specific configurations have issues.

Exclude and include refine the matrix. Exclude removes specific combinations, while include adds additional configurations or variables.

Conditional steps with if: matrix.coverage run only for specific matrix configurations.

Caching for Performance

Effective caching dramatically reduces build times:

name: Optimized CI with Caching

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Cache node_modules
        uses: actions/cache@v4
        id: cache-modules
        with:
          path: node_modules
          key:
            ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json')
            }}
          restore-keys: |
            ${{ runner.os }}-node-modules-

      - name: Install dependencies
        if: steps.cache-modules.outputs.cache-hit != 'true'
        run: npm ci

      - name: Cache build output
        uses: actions/cache@v4
        with:
          path: |
            .next/cache
            dist
          key: ${{ runner.os }}-build-${{ hashFiles('src/**') }}
          restore-keys: |
            ${{ runner.os }}-build-

      - name: Build application
        run: npm run build

Cache keys should include variables that invalidate the cache when dependencies change. The hashFiles() function generates a hash from file contents.

Restore keys provide fallback options when exact matches aren't found. This enables partial cache hits.

Conditional installation skips npm ci when dependencies are fully cached, saving significant time on repeated builds.

Docker Image Building

Containerized deployments require building and pushing Docker images:

name: Docker Build and Push

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:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to 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=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            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

Docker Buildx enables advanced build features including multi-platform images and improved caching.

GitHub Container Registry (ghcr.io) integrates seamlessly with GitHub Actions using the built-in GITHUB_TOKEN.

Metadata action automatically generates appropriate tags based on Git refs, semantic versioning, and commit SHAs.

Layer caching with cache-from and cache-to dramatically speeds up subsequent builds by reusing unchanged layers.

Deployment Workflows

Automating deployments requires careful consideration of environments, approvals, and rollback strategies.

Staging Deployment

name: Deploy to Staging

on:
  push:
    branches: [develop]

env:
  STAGING_URL: https://staging.example.com

jobs:
  deploy-staging:
    name: Deploy to Staging Environment
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: ${{ env.STAGING_URL }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build for staging
        run: npm run build
        env:
          NODE_ENV: staging
          API_URL: ${{ secrets.STAGING_API_URL }}

      - name: Deploy to staging server
        run: |
          echo "Deploying to staging environment..."
          # Add your deployment commands here
          # Examples:
          # - rsync to server
          # - AWS S3 sync
          # - Cloudflare Pages deploy
          # - Kubernetes apply
        env:
          DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}

      - name: Run smoke tests
        run: |
          echo "Running smoke tests against staging..."
          curl -f ${{ env.STAGING_URL }}/health || exit 1

      - name: Notify deployment
        if: always()
        run: |
          if [ "${{ job.status }}" == "success" ]; then
            echo "Staging deployment successful!"
          else
            echo "Staging deployment failed!"
          fi

Environments provide deployment protection rules, required reviewers, and deployment history. Configure environments in repository Settings and reference them in workflows.

Environment secrets are separate from repository secrets, enabling different credentials per environment.

Smoke tests verify the deployment succeeded by checking critical endpoints.

Production Deployment with Approvals

name: Deploy to Production

on:
  release:
    types: [published]
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to deploy'
        required: true

env:
  PRODUCTION_URL: https://example.com

jobs:
  validate:
    name: Pre-deployment Validation
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run full test suite
        run: npm test

      - name: Security audit
        run: npm audit --audit-level=high

      - name: Build verification
        run: npm run build

  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: validate
    environment:
      name: production
      url: ${{ env.PRODUCTION_URL }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build for production
        run: npm run build
        env:
          NODE_ENV: production
          API_URL: ${{ secrets.PRODUCTION_API_URL }}

      - name: Create deployment record
        id: deployment
        run: |
          echo "version=${{ github.event.release.tag_name || github.event.inputs.version }}" >> $GITHUB_OUTPUT
          echo "timestamp=$(date -u +%Y%m%d%H%M%S)" >> $GITHUB_OUTPUT

      - name: Deploy to production
        run: |
          echo "Deploying version ${{ steps.deployment.outputs.version }}..."
          # Production deployment commands
        env:
          DEPLOY_TOKEN: ${{ secrets.PRODUCTION_DEPLOY_TOKEN }}

      - name: Verify deployment health
        run: |
          echo "Verifying production deployment..."
          for i in {1..5}; do
            if curl -sf ${{ env.PRODUCTION_URL }}/health; then
              echo "Health check passed!"
              exit 0
            fi
            echo "Attempt $i failed, retrying in 10s..."
            sleep 10
          done
          echo "Health check failed after 5 attempts"
          exit 1

      - name: Create deployment summary
        run: |
          echo "## Production Deployment Summary" >> $GITHUB_STEP_SUMMARY
          echo "- **Version**: ${{ steps.deployment.outputs.version }}" >> $GITHUB_STEP_SUMMARY
          echo "- **Timestamp**: ${{ steps.deployment.outputs.timestamp }}" >> $GITHUB_STEP_SUMMARY
          echo "- **Status**: Success โœ…" >> $GITHUB_STEP_SUMMARY
          echo "- **URL**: ${{ env.PRODUCTION_URL }}" >> $GITHUB_STEP_SUMMARY

  notify:
    name: Post-deployment Notifications
    runs-on: ubuntu-latest
    needs: deploy-production
    if: always()

    steps:
      - name: Send success notification
        if: needs.deploy-production.result == 'success'
        run: |
          echo "Production deployment completed successfully!"
          # Add Slack, Discord, or email notification here

      - name: Send failure notification
        if: needs.deploy-production.result == 'failure'
        run: |
          echo "Production deployment failed!"
          # Add failure notification here

Release triggers automate production deployments when you publish GitHub releases.

Pre-deployment validation ensures code quality before production deployment.

Health checks with retry handle transient failures during deployment.

Job summaries with $GITHUB_STEP_SUMMARY create rich deployment reports visible in the Actions UI.

Secrets and Security

Proper secrets management is critical for secure CI/CD pipelines.

Secrets Best Practices

name: Secure Workflow

on:
  push:
    branches: [main]

jobs:
  secure-job:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Use secrets safely
        run: |
          # Never echo secrets directly
          # Use environment variables instead
          npm run deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}

      - name: Mask sensitive output
        run: |
          TOKEN=$(get-temporary-token)
          echo "::add-mask::$TOKEN"
          echo "Token retrieved successfully"

Minimal permissions with the permissions key restrict what the workflow can access. Always specify only the permissions you need.

OIDC authentication (OpenID Connect) for cloud providers eliminates long-lived credentials. The id-token: write permission enables this.

Environment variables prevent secrets from appearing in logs. Never interpolate secrets directly into shell commands.

Masking with ::add-mask:: hides dynamically generated sensitive values from logs.

Repository and Environment Secrets

Configure secrets at appropriate levels:

Repository secrets apply to all workflows in the repository. Use for API keys, deployment tokens, and credentials shared across environments.

Environment secrets apply only to jobs referencing that environment. Use for environment-specific credentials like staging vs production database URLs.

Organization secrets apply across multiple repositories. Use for shared credentials like container registry access.

Advertisement

Reusable Workflows

Reusable workflows reduce duplication across repositories and enforce consistent patterns.

Creating a Reusable Workflow

Create .github/workflows/reusable-ci.yml:

name: Reusable CI Workflow

on:
  workflow_call:
    inputs:
      node_version:
        description: 'Node.js version'
        required: false
        type: string
        default: '20'
      run_coverage:
        description: 'Generate coverage report'
        required: false
        type: boolean
        default: false
      working_directory:
        description: 'Working directory for npm commands'
        required: false
        type: string
        default: '.'
    secrets:
      npm_token:
        description: 'NPM authentication token'
        required: false
    outputs:
      test_result:
        description: 'Test execution result'
        value: ${{ jobs.test.outputs.result }}

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working_directory }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node_version }}
          cache: 'npm'
          cache-dependency-path:
            ${{ inputs.working_directory }}/package-lock.json

      - name: Install dependencies
        run: npm ci
        env:
          NPM_TOKEN: ${{ secrets.npm_token }}

      - name: Run linting
        run: npm run lint

  test:
    name: Test
    runs-on: ubuntu-latest
    needs: lint
    outputs:
      result: ${{ steps.test.outputs.result }}
    defaults:
      run:
        working-directory: ${{ inputs.working_directory }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node_version }}
          cache: 'npm'
          cache-dependency-path:
            ${{ inputs.working_directory }}/package-lock.json

      - name: Install dependencies
        run: npm ci
        env:
          NPM_TOKEN: ${{ secrets.npm_token }}

      - name: Run tests
        id: test
        run: |
          if npm test; then
            echo "result=success" >> $GITHUB_OUTPUT
          else
            echo "result=failure" >> $GITHUB_OUTPUT
            exit 1
          fi

      - name: Generate coverage
        if: inputs.run_coverage
        run: npm test -- --coverage

      - name: Upload coverage
        if: inputs.run_coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: ${{ inputs.working_directory }}/coverage/

Calling a Reusable Workflow

name: CI Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  ci:
    uses: ./.github/workflows/reusable-ci.yml
    with:
      node_version: '20'
      run_coverage: true
    secrets:
      npm_token: ${{ secrets.NPM_TOKEN }}

  post-ci:
    needs: ci
    runs-on: ubuntu-latest

    steps:
      - name: Check CI result
        run: |
          echo "CI test result: ${{ needs.ci.outputs.test_result }}"

Workflow inputs define configurable parameters. Callers can override defaults.

Secrets inheritance requires explicit passing unless you use secrets: inherit.

Outputs enable passing data from reusable workflows back to callers.

Conditional Execution Patterns

Control when jobs and steps execute with conditional expressions.

Common Conditional Patterns

name: Conditional Workflow

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  conditional-job:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Only on main branch
        if: github.ref == 'refs/heads/main'
        run: echo "Running on main branch"

      - name: Only on pull requests
        if: github.event_name == 'pull_request'
        run: echo "This is a pull request"

      - name: Only when specific files changed
        if: contains(github.event.head_commit.modified, 'src/')
        run: echo "Source files were modified"

      - name: Only on success
        if: success()
        run: echo "All previous steps succeeded"

      - name: Only on failure
        if: failure()
        run: echo "A previous step failed"

      - name: Always run (cleanup)
        if: always()
        run: echo "This always runs"

      - name: Check for specific label
        if: contains(github.event.pull_request.labels.*.name, 'deploy')
        run: echo "PR has deploy label"

      - name: Skip for bot commits
        if: "!contains(github.event.head_commit.author.name, '[bot]')"
        run: echo "Not a bot commit"

  skip-ci:
    runs-on: ubuntu-latest
    if: "!contains(github.event.head_commit.message, '[skip ci]')"

    steps:
      - run: echo "CI not skipped"

  deployment-gate:
    runs-on: ubuntu-latest
    if: |
      github.ref == 'refs/heads/main' &&
      github.event_name == 'push' &&
      !contains(github.event.head_commit.message, '[no-deploy]')

    steps:
      - run: echo "Deployment conditions met"

Branch conditions control environment-specific behavior.

Event type checks differentiate between pushes, PRs, and other triggers.

Status functions (success(), failure(), always(), cancelled()) respond to workflow state.

Commit message parsing enables skip patterns and deployment flags.

Multi-line conditions with YAML pipe syntax improve readability for complex logic.

Debugging and Troubleshooting

When workflows fail, these techniques help identify and resolve issues.

Enable Debug Logging

Set these repository secrets to enable verbose output:

  • ACTIONS_RUNNER_DEBUG: true - Enables runner diagnostic logging
  • ACTIONS_STEP_DEBUG: true - Enables step debug logging

Debugging Steps

name: Debug Workflow

on:
  workflow_dispatch:

jobs:
  debug:
    runs-on: ubuntu-latest

    steps:
      - name: Dump GitHub context
        run: echo '${{ toJSON(github) }}'

      - name: Dump job context
        run: echo '${{ toJSON(job) }}'

      - name: Dump steps context
        run: echo '${{ toJSON(steps) }}'

      - name: Dump runner context
        run: echo '${{ toJSON(runner) }}'

      - name: Dump environment variables
        run: env | sort

      - name: Check filesystem
        run: |
          pwd
          ls -la
          df -h

      - name: Interactive debugging
        if: failure()
        uses: mxschmitt/action-tmate@v3
        with:
          limit-access-to-actor: true
          timeout-minutes: 15

Context dumps reveal available variables and their values.

tmate action provides SSH access to a failed runner for interactive debugging. Use sparingly and always set timeouts.

Common Issues and Solutions

Permission denied errors: Check repository settings for Actions permissions. Ensure GITHUB_TOKEN has required scopes.

Cache misses: Verify cache keys include all variables that should invalidate the cache. Check key patterns match expected format.

Timeout errors: Long-running jobs may need increased timeouts. Add timeout-minutes to job or step definitions.

Concurrent execution issues: Use concurrency groups to prevent overlapping runs:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Complete Example Application

The tutorial repository includes a complete Node.js application demonstrating all workflow patterns.

Package Configuration

{
  "name": "crashbytes-tutorial-github-actions",
  "version": "1.0.0",
  "description": "GitHub Actions CI/CD Tutorial",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js",
    "build": "npm run lint && npm test",
    "test": "jest --passWithNoTests",
    "test:coverage": "jest --coverage",
    "lint": "eslint src/ tests/",
    "lint:fix": "eslint src/ tests/ --fix"
  },
  "devDependencies": {
    "eslint": "^8.57.0",
    "jest": "^29.7.0",
    "nodemon": "^3.1.0"
  }
}

Application Code

// src/index.js
const { greet, calculateSum, validateEmail } = require('./utils')
const { healthCheck, getStatus } = require('./api')

const PORT = process.env.PORT || 3000

function main() {
  console.log(greet('GitHub Actions'))
  console.log('Sum of 1-10:', calculateSum([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))
  console.log(
    'Valid email test@example.com:',
    validateEmail('test@example.com')
  )
  console.log('Health check:', healthCheck())
  console.log('Status:', getStatus())
}

if (require.main === module) {
  main()
}

module.exports = { main }
// src/utils.js
function greet(name) {
  return `Hello, ${name}!`
}

function calculateSum(numbers) {
  return numbers.reduce((sum, num) => sum + num, 0)
}

function validateEmail(email) {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  return emailRegex.test(email)
}

module.exports = { greet, calculateSum, validateEmail }
// src/api.js
function healthCheck() {
  return { status: 'healthy', timestamp: new Date().toISOString() }
}

function getStatus() {
  return {
    version: process.env.npm_package_version || '1.0.0',
    environment: process.env.NODE_ENV || 'development',
    uptime: process.uptime(),
  }
}

module.exports = { healthCheck, getStatus }

Test Suite

// tests/index.test.js
const { greet, calculateSum, validateEmail } = require('../src/utils')
const { healthCheck, getStatus } = require('../src/api')

describe('Utils', () => {
  describe('greet', () => {
    it('should return greeting with name', () => {
      expect(greet('World')).toBe('Hello, World!')
    })

    it('should handle empty string', () => {
      expect(greet('')).toBe('Hello, !')
    })
  })

  describe('calculateSum', () => {
    it('should sum array of numbers', () => {
      expect(calculateSum([1, 2, 3])).toBe(6)
    })

    it('should return 0 for empty array', () => {
      expect(calculateSum([])).toBe(0)
    })

    it('should handle negative numbers', () => {
      expect(calculateSum([-1, 1])).toBe(0)
    })
  })

  describe('validateEmail', () => {
    it('should validate correct email', () => {
      expect(validateEmail('test@example.com')).toBe(true)
    })

    it('should reject invalid email', () => {
      expect(validateEmail('invalid-email')).toBe(false)
    })

    it('should reject email without domain', () => {
      expect(validateEmail('test@')).toBe(false)
    })
  })
})

describe('API', () => {
  describe('healthCheck', () => {
    it('should return healthy status', () => {
      const result = healthCheck()
      expect(result.status).toBe('healthy')
      expect(result.timestamp).toBeDefined()
    })
  })

  describe('getStatus', () => {
    it('should return status object', () => {
      const result = getStatus()
      expect(result.version).toBeDefined()
      expect(result.environment).toBeDefined()
      expect(result.uptime).toBeGreaterThanOrEqual(0)
    })
  })
})

Dockerfile

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./

RUN npm ci --only=production

COPY src/ ./src/

USER node

EXPOSE 3000

CMD ["node", "src/index.js"]

Workflow Optimization Tips

Maximize efficiency and minimize costs with these optimization strategies.

Reduce Workflow Duration

Parallelize independent jobs: Jobs without dependencies run simultaneously. Structure your workflow to maximize parallelism.

Use appropriate runners: ubuntu-latest is fastest for most workloads. Use macos-latest or windows-latest only when necessary.

Skip unnecessary work: Use path filters to avoid running workflows when irrelevant files change.

Cache aggressively: Cache dependencies, build outputs, and anything expensive to recreate.

Minimize Costs

GitHub Actions charges based on minutes consumed. These strategies reduce costs:

Use concurrency groups: Cancel redundant runs when new commits arrive.

Implement early termination: Fail fast on critical checks before expensive operations.

Optimize Docker builds: Use multi-stage builds and layer caching.

Review billing regularly: Monitor Actions usage in repository settings.

Summary and Best Practices

GitHub Actions provides powerful CI/CD automation that scales from simple workflows to complex enterprise pipelines.

Key Takeaways

Start simple: Begin with basic workflows and add complexity as needed. A working simple workflow beats a broken complex one.

Use caching: Proper caching can reduce build times by 50 percent or more. Always cache dependencies and consider caching build outputs.

Secure your secrets: Never expose secrets in logs. Use OIDC where possible to eliminate long-lived credentials.

Embrace reusability: Extract common patterns into reusable workflows. This reduces maintenance and ensures consistency.

Monitor and optimize: Review workflow run times regularly. Identify bottlenecks and address them systematically.

Document your workflows: Future you will appreciate comments explaining non-obvious configurations.

Next Steps

  1. Clone the tutorial repository: Get hands-on with the example workflows
  2. Adapt workflows to your project: Start with the basic CI workflow and customize
  3. Implement deployment automation: Add staging and production deployments
  4. Explore the marketplace: Find actions that solve common problems
  5. Share reusable workflows: Create organization-wide workflow templates

The complete source code and all workflow files are available at github.com/CrashBytes/ByteSizedExamples/tree/main/crashbytes-tutorial-github-actions-ci-cd.

GitHub Actions transforms how teams ship software. With the patterns in this tutorial, you're equipped to build CI/CD pipelines that are fast, reliable, and maintainable. Start with the basics, iterate based on your team's needs, and continuously improve your automation.

Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

tutorialhands-ongithub-actionsci-cddevopsautomationcontinuous-integrationcontinuous-deploymentworkflow-automationcode-examplesbest-practices
Back to Articles
โ† PreviousHumanoid Robots in the Enterprise: The 2025 Implementation Playbook for CTOs and Engineering LeadersNext โ†’The LinkedIn Update Paradox: Why Your Profile Activity Directly Correlates with Recruiter Contact Frequency

From across the CrashBytes network

More than the blog โ€” predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to tutorial and expand your knowledge.

๐Ÿ“„tutorial

Tutorial: Building Production MLOps Pipelines on Kubernetes with Kubeflow

Build enterprise-grade MLOps pipelines on Kubernetes with automated training, validation, deployment, and monitoring. Complete implementation with Kubeflow, model registry, and production patterns.

20 min readRead more
๐Ÿ“„tutorial

Tutorial: Zero-Downtime Database Migrations - Enterprise Patterns for Legacy System Modernization

Master zero-downtime database migrations for mission-critical systems. Complete implementation with blue-green deployments, data synchronization, rollback strategies, and testing patterns for enterprise databases.

17 min readRead more
๐Ÿ“„tutorial

Tutorial - NFT Smart Contract Development with Web3 and Ethereum - Full Stack Implementation from Zero to Production

Build production-ready NFT smart contracts from scratch with Solidity, OpenZeppelin, and Web3.js. Complete tutorial covering ERC-721 implementation, IPFS metadata storage, minting functionality, and full-stack integration patterns.

28 min readRead more
๐Ÿ“„tutorial

Tutorial: Enterprise AI Model Monitoring and Observability in Production Kubernetes Environments

Learn to build production-grade AI model monitoring with drift detection, performance tracking, and automated alerting. Complete implementation with Prometheus, Grafana, and Kubernetes deployment patterns.

18 min readRead more