GitHub Actions: Build, Test, Deploy in 60 Lines of YAML
GitHub Actions provides a powerful, event-driven automation framework directly integrated within GitHub repositories. This article details how to…
GitHub Actions provides a powerful, event-driven automation framework directly integrated within GitHub repositories. This article details how to construct a robust Continuous Integration/Continuous Deployment (CI/CD) pipeline using GitHub Actions, focusing on building, testing, and deploying a sample Node.js application in a concise YAML definition.
Understanding GitHub Actions Fundamentals
GitHub Actions workflows are defined in YAML files (typically .github/workflows/*.yml) within your repository. Each workflow is triggered by specific events and comprises one or more "jobs." A "job" is a set of "steps" that execute on a "runner," which is a virtual machine (e.g., Ubuntu, Windows, macOS) or a container. "Steps" can be commands executed directly on the runner or pre-built "actions" from the GitHub Marketplace.
Workflow Structure Overview
name: A human-readable name for your workflow.on: Defines the events that trigger the workflow (e.g.,push,pull_request,workflow_dispatch).jobs: A collection of jobs that run in parallel by default, or sequentially if dependencies are defined.jobs.: Specifies the type of runner to use (e.g.,.runs-on ubuntu-latest,windows-2022).jobs.: A sequence of tasks to be performed within the job..steps uses: Executes a community or custom action. Format:owner/repo@refor./.github/actions/my-action.run: Executes a command-line script.
Setting Up the Basic CI Pipeline (Build & Test)
Let's start with a foundational CI pipeline for a Node.js application. This will check out the code, install dependencies, and run tests. For this example, we'll assume a standard Node.js project with package.json and a test script defined.
name: CI Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
workflow_dispatch: # Allows manual trigger from GitHub UI
jobs:
build_and_test:
runs-on: ubuntu-latest # Or 'windows-latest', 'macos-latest'
strategy:
matrix:
node-version: [18.x, 20.x] # Test against multiple Node.js versions
steps:
- name: Checkout repository
uses: actions/checkout@v4 # Action to check out your repository code.
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4 # Action to set up a Node.js environment.
with:
node-version: ${{ matrix.node-version }}
cache: 'npm' # Caches node modules for faster subsequent runs.
- name: Install dependencies
run: npm ci # 'npm ci' for clean install in CI environments, uses package-lock.json
- name: Run tests
run: npm test # Executes the 'test' script defined in package.json
- name: Build application (if applicable)
run: npm run build # Executes the 'build' script. Omit if not needed.
In this snippet:
on.push.branchesandon.pull_request.branchesspecify that the workflow runs only for changes onmainanddevelopbranches.workflow_dispatchenables manual triggering from the "Actions" tab in GitHub.- The
strategy.matrixconfiguration allows testing against Node.js 18 and 20 concurrently, generating two separate job runs. This is crucial for compatibility checks. actions/checkout@v4checks out your repository code. Always specify a version (e.g.,@v4) to ensure stability and reproducibility.actions/setup-node@v4configures the Node.js environment. Thecache: 'npm'option optimizes dependency installation by caching thenode_modulesdirectory, significantly reducing job execution time on subsequent runs.npm ciis preferred overnpm installin CI/CD environments as it ensures a clean installation based onpackage-lock.json, preventing dependency drift.npm run buildexecutes your project's build command, generating production-ready assets.
Integrating Advanced Testing and Linting
Beyond basic unit tests, CI pipelines often include linting, code style checks, and more comprehensive testing suites.
Linting and Static Analysis
Adding a linting step ensures code quality and consistency.
...
- name: Run ESLint
run: npm run lint # Assumes 'lint' script in package.json (e.g., 'eslint .')
continue-on-error: true # Allows subsequent steps to run even if linting fails, but marks job as 'failed'
...
continue-on-error: true is useful if you want to see all failures (tests, build, lint) in a single run, rather than stopping at the first linting error. The job will still be marked as failed if any step with this option fails.
Test Reporting and Code Coverage
Tools like Jest can generate JUnit XML reports and code coverage reports. These can be uploaded as artifacts.
...
- name: Run tests with coverage
run: npm test -- --coverage --ci --json --outputFile=test-results.json # Example for Jest
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.node-version }}
path: test-results.json
retention-days: 7 # How long to keep the artifact
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ matrix.node-version }}
path: coverage/lcov-report # Assuming lcov-report is generated by Jest
retention-days: 7
...
actions/upload-artifact@v4 allows you to store files generated during the workflow run. These artifacts can be downloaded later from the GitHub Actions UI, useful for debugging or compliance.
Implementing Deployment Logic (CD)
Deployment strategies vary widely. Here, we'll demonstrate a simple deployment to an S3 bucket for a static site, triggered only on pushes to the main branch, and dependent on the successful completion of the build_and_test job.
name: CI/CD Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
workflow_dispatch:
jobs:
build_and_test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x] # Only build/test with Node 20.x for deployment for simplicity
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build application
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: build-output
path: build/ # Assuming your build output is in a 'build/' directory
retention-days: 1 # Only keep artifact for 1 day, useful for quick deploys
deploy_to_s3:
needs: build_and_test # This job will only run after build_and_test successfully completes
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Only deploy if the push is to the 'main' branch
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: build-output
path: build/ # Download the artifact to this directory
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4 # Action for setting up AWS access
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1 # Specify your AWS region
- name: Deploy to S3
run: aws s3 sync build/ s3://my-static-website-bucket --delete # Syncs local build/ to S3
env:
AWS_MAX_ATTEMPTS: 5 # Retry failed S3 operations
AWS_RETRY_MODE: standard
Key elements of the deployment job:
needs: build_and_test: Establishes a dependency, ensuring deployment only proceeds if the CI job is successful.if: github.ref == 'refs/heads/main': A conditional expression that restricts this job to run only for pushes to themainbranch.github.refholds the branch or tag that triggered the workflow.actions/download-artifact@v4: Retrieves the build output generated by thebuild_and_testjob. This decouples the build and deploy environments, making the pipeline more resilient.aws-actions/configure-aws-credentials@v4: An official AWS action for securely configuring AWS credentials. It uses OpenID Connect (OIDC) recommended approach if configured, or falls back to traditional access keys. EnsureAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYare stored as GitHub Secrets in your repository settings.aws s3 sync build/ s3://my-static-website-bucket --delete: Uses the AWS CLI (pre-installed onubuntu-latestrunners) to synchronize the localbuild/directory with your S3 bucket.--deleteensures that files removed from your local build are also removed from S3.
Common Pitfalls and Troubleshooting
- Missing
package-lock.json(or equivalent): Usingnpm ciwithout apackage-lock.jsonwill fail. Always commit your lock files. - Secrets Management: Never hardcode sensitive information. Use GitHub Secrets (
${{ secrets.SECRET_NAME }}) for API keys, AWS credentials, etc. - Incorrect Paths: Relative paths in
runcommands oractions/upload-artifactmight be incorrect depending on the runner's current working directory. Always double-check. - Race Conditions with Artifacts: If multiple jobs create artifacts with the same name, they can overwrite each other. Use unique names, potentially including
${{ github.run_id }}or job context variables. - Action Versions: Always pin actions to specific versions (e.g.,
@v4,@main, or a full SHA) instead of floating versions (e.g.,@latest) to ensure reproducibility and prevent unexpected breaking changes. - Debugging Failures: GitHub Actions provides detailed logs for each step. Examine these logs carefully. You can also re-run failed jobs. For complex issues, consider adding
echoorls -laRcommands in your steps to inspect the environment or file system. - Runner Resource Limits: Be aware of the free tier limits for GitHub-hosted runners (2000 minutes/month for public repos, 500 minutes for private repos, subject to change). Optimize caching and parallelization to minimize run time.