A Guide to CI/CD Workflows with GitHub Actions

A Guide to CI/CD Workflows with GitHub Actions

GitHub Actions is a critical tool for automating workflows such as code deployment and repository management. In my article, I will dive deep into the importance of GitHub Actions, based on insights from the Udemy Course This knowledge is especially crucial for Quality Assurance (QA) professionals, as it enables them to ensure seamless integration, testing, and deployment processes, all of which are vital for maintaining high software quality.

Understanding CI/CD pipelines through GitHub Actions allows QA teams to automate testing, build more reliable workflows, and reduce manual intervention. These skills help QA professionals improve code quality, catch bugs earlier, and ultimately deliver better software faster.


Creating Your First GitHub Actions Workflow

To start automating tasks with GitHub Actions, you’ll create a YAML file in your repository that defines your workflow. The file should follow YAML syntax, where indentation is crucial for the correct interpretation of jobs, steps, and events.

Example: First Workflow

name: First Workflow

# When should this workflow run?
on: 
  workflow_dispatch:  # Allows manual triggering of the workflow

jobs:
  firstjob:
    runs-on: ubuntu-latest
    steps:
      - name: Print greeting
        run: echo "Hello World!"        

Workflow Components:

  • Events (Triggers): Define when the workflow should run, such as on push, pull requests, or manually (workflow_dispatch).
  • Jobs: A job defines the environment where steps run, for example, ubuntu-latest.
  • Steps: These are the individual commands that make up your workflow, like printing a message or running a script.

Each component in GitHub Actions is designed to offer flexibility and power, enabling developers to automate their software development processes efficiently.

GitHub Actions' billing is particularly relevant for users who exceed the free quotas included in their GitHub account.



6. Detailed Workflow Explanation

1. Event Triggers

In GitHub Actions, the on keyword defines when a workflow should be triggered. There are a variety of supported events, both repository-related and general:

  • Repository-Related Events: These are common activities that trigger workflows based on repository changes or updates.
  • push: Triggered when code is pushed to a branch. Event Filters: Event filters allow you to control when workflows should run. For example, you can filter based on branches or file paths
  • Branches Filter: Limit workflows to run only on certain branches, like main or dev-*.
  • Paths-Ignore Filter: Use this to skip running workflows if only specific files were changed

on:
  push:
    branches:
      - main
      - 'dev-*'
    paths-ignore:
      - '.github/workflows/*'        

  • pull_request: Triggered when a pull request is opened, updated, or merged.

on:
  pull_request:
    types: [opened, edited, closed]         

  • fork, watch, issues, discussion, create, issue_comment: These events cover a wide range of repository actions, from watching a repository to commenting on an issue.

Other Events:

These events allow more flexibility in workflow execution, outside of regular repository updates.

  • workflow_dispatch: A manual trigger that allows users to run a workflow directly from the GitHub Actions UI.

on:
  workflow_dispatch:
    inputs:
      browser:
        description: 'Browser to run tests on'
        required: true
        default: 'chrome'
        type: choice
        options:
          - 'chrome'
          - 'firefox'        

  • schedule: Allows workflows to run on a defined schedule, using cron syntax (e.g., every day at 2 AM).

on:
  schedule:
    - cron: '00 08 * * *'        

  • repository_dispatch: Triggered by custom events from external services or tools.
  • workflow_call: This allows workflows to be reused, as they can be triggered by other workflows.

For more events and details, refer to GitHub Events Documentation


2. Jobs

A job defines the tasks that will be performed. Each job can have multiple steps, and jobs can be run in parallel or sequentially. Here's how you can set up jobs:

  • Where to Run Jobs: Use runs-on to specify the environment for your jobs (e.g., ubuntu-latest for an Ubuntu runner).
  • Job Strategies: You can use strategy to set up a matrix for parallel execution, allowing tasks to run in different environments or configurations simultaneously.

Example:

jobs:
  test:
    runs-on: ubuntu-latest        

  • Steps: These are individual commands within a job, like checking out the code, installing dependencies, and running tests.

Example of steps:

steps:
  - name: Checkout code
    uses: actions/checkout@v3
  
  - name: Install dependencies
    run: npm ci

  - name: Run tests
    run: npm test        

  1. Caching Dependencies

Installing dependencies can take time, especially in large projects. You can cache dependencies to reuse them in future runs, reducing the time spent reinstalling. The actions/cache action stores files (e.g., node_modules), so if your dependencies haven’t changed, they’ll be reused.

Here’s a quick setup:

  - name: Cache NPM dependencies
    uses: actions/cache@v3
    with:
      path: ~/.npm
      key: deps-node-module-${{ hashFiles('**/package-lock.json') }}        
      - name: Cache Yarn dependencies
        uses: actions/cache@v2
        with:
          path: |
            node_modules
            .yarn/cache
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-        

By caching, you skip redundant installations, speeding up workflows, especially when dependencies don’t change frequently. You should repeat this step across all jobs that need the same dependencies.

2. Environment Variables & Secrets:

  • These are variables accessible throughout your workflow. They store values such as paths, URLs, or custom configuration data.
  • Environment variables can be defined globally, at the job level, or within specific steps.
  • Secrets store sensitive data such as API keys, passwords, or tokens, and are encrypted within GitHub. They are not exposed in the logs or the UI.
  • Secrets are defined in the repository settings and accessed within workflows using secrets.

jobs:
  tests:
      API_KEY: ${{ secrets.API_KEY }}        

In your project code, you can access these variables using the relevant environment variable method. For example, in Node.js, you'd use process.env.

Use .env files for local development, and load them using libraries like dotenv in Node.js projects:

require('dotenv').config()
console.log(process.env.MY_VARIABLE)        

Best Practices:

  • Always use secrets for sensitive information like keys and tokens.
  • Scope environment variables only to the jobs or steps where they are needed.
  • Avoid hardcoding sensitive data into the workflow files.

3. Using the needs Keyword for Job Dependencies:

  • In more complex workflows, you may have multiple jobs that depend on the completion of earlier jobs. This is where the needs keyword comes into play.
  • The needs keyword specifies that a job should only run if a previous job has been completed successfully. This is useful when the execution of one job depends on the output or success of another job.
  • needs: <nameJob>: The test job will only run after the build job has finished successfully.
  • needs: [<nameJob1>, <nameJob2>]: The deploy job will run only after both the build and test jobs have completed successfully

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Build the project
        run: echo "Building the project"

  test:
    runs-on: ubuntu-latest
    needs: build  # Waits for 'build' job to complete
    steps:
      - name: Run tests
        run: echo "Running tests"

  deploy:
    runs-on: ubuntu-latest
    needs: [build, test]  # Waits for both 'build' and 'test' jobs to complete
    steps:
      - name: Deploy the project
        run: echo "Deploying the project"        

4. Jobs and Docker Containers in GitHub Actions

In GitHub Actions, Docker containers allow you to define a fully controlled environment for running your jobs, making them ideal for creating reproducible execution environments. Containers bundle the code with dependencies, ensuring that jobs run consistently across different systems.

Key Concepts:

  • Containers: They provide full control over the environment and installed software, ensuring consistent execution.
  • Predefined Runners: GitHub offers predefined environments (like ubuntu-latest), but using containers gives you more flexibility.
  • Service Containers: These containers run alongside your jobs to provide additional functionality, like a testing database. For example, you can use a service container to host a testing database, preventing your job from manipulating the production database during tests.

Using Docker and service containers enhances job isolation, maintains consistent results, and avoids affecting critical systems during tasks like testing.


3. Cancelling and Skipping Workflows

Workflows can be automatically cancelled if jobs fail or they can be manually cancelled via the GitHub UI. You can also use concurrency control to cancel redundant workflows:

  • Automatic Cancellation: Workflows are automatically cancelled if jobs fail or do not meet specific criteria. This behavior ensures that redundant jobs are not executed.
  • Manual Cancellation: You can cancel workflows manually through the GitHub Actions interface when a job is no longer needed or stuck.
  • Concurrency Control: GitHub allows you to cancel redundant workflows that are in progress when a new one is triggered. This helps optimize your CI/CD process by avoiding unnecessary resource usage.
  • Skipping Workflows: You can skip workflows by including special annotations in your commit messages. For example, adding [skip ci] in a commit message prevents the workflow from running on that commit.
  • Continue-on-error: Sometimes, you might want a step to continue even if it fails (e.g., non-critical tasks). You can use the continue-on-error keyword for this.

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

steps: 
  - name: Run tests
    run: npm run test
    continue-on-error: true #Even if the tests fail, the workflow will proceed with the next steps.        

4. Artifacts and Job Outputs

Artifacts are files or folders created during the execution of a job that you want to save or share between jobs. Common use cases include logs, test results, or build files (e.g., app binaries, package files). Artifacts help ensure continuity when you're running multiple jobs or workflows.

Job outputs are simple values that can be passed between jobs. This is especially useful when one job produces information or data needed by another job in the same workflow.

Conditionals in GitHub Actions

In GitHub Actions, conditional expressions like failure(), success(), always(), and cancelled() are used to control whether or not certain steps or jobs run based on the outcome of previous steps or jobs. Here's an explanation of each condition and how they are typically used:

  • failure(): The failure() function checks if any previous step in the current job has failed. If any step fails, the condition is met, and the steps with if: failure() will run.
  • success(): The success() function checks if all previous steps in the current job have succeeded. If all steps are successful, the steps with if: success() will run.
  • always(): The always() function ensures that a step runs no matter the outcome of previous steps, whether they succeed, fail, or are cancelled. This is useful for cleanup tasks that should run regardless of success or failure.
  • cancelled(): The cancelled() function checks if the workflow was cancelled. If the workflow is cancelled, the steps with if: cancelled() will run. This is useful for steps that should execute if the job is interrupted, such as rollback operations.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Install dependencies
        run: npm ci

      - name: Run tests
        id: run-tests
        run: npm run test

      - name: Upload test report if tests fail
        if: failure() # This condition checks if the previous step failed
        uses: actions/upload-artifact@v3
        with:
          name: test-report
          path: test.json        

Explanation:

  • if: failure(): This conditional checks if the previous step failed. If the test step fails, the next step will be executed.
  • id: run-tests: Assigning an ID to the test step allows you to reference its outcome in more complex conditionals if needed.

This will ensure that the test report is only uploaded when the tests fail, keeping your workflow efficient.


5. Matrix Strategy in GitHub Actions

The matrix strategy is a powerful feature that allows you to run jobs with multiple configurations in parallel, making it ideal for testing across different environments (e.g., operating systems, and programming language versions). Instead of duplicating jobs for each configuration, the matrix allows you to define multiple variations that run simultaneously.

Key Points:

  1. Matrix: Define various configurations such as Node.js versions, Python versions, operating systems, etc.
  2. Include/Exclude:

  • Include: Add specific configurations not covered by the main matrix.
  • Exclude: Skip specific combinations that don't need testing.
  • Parallel Execution: Each matrix configuration runs as an independent job in parallel, speeding up the workflow and testing different environments simultaneously.
  • Fail-Fast: The fail-fast option can be set to false if you don't want the entire matrix to stop running when one job fails.

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        node-version: [12, 14, 16]
        os: [ubuntu-latest, windows-latest]
      include:
        - node-version: 18
          os: ubuntu-latest
      exclude:
        - node-version: 12
          os: windows-latest
    runs-on: ${{ matrix.os }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}        

6. Reusable Workflows, Passing Inputs, Secrets, and Outputs

Reusable workflows in GitHub Actions allow you to centralize common logic in one workflow and use it in multiple repositories or workflows. This makes it easier to maintain and update automation processes.

Key Concepts:

  • workflow_call: This event type is used to call reusable workflows. It makes a workflow reusable across different repositories or workflows.
  • Passing Inputs and Secrets: You can pass inputs (e.g., file names, paths) and secrets (e.g., API keys) from one workflow to another.
  • Passing Outputs: Outputs from one reusable workflow can be passed to other workflows, enabling you to create dynamic workflows where the result of one process is used in another.

Reusable Workflow Example:

name: Reusable Deploy
on: 
  workflow_call:
    inputs:
      artifact-name:
        description: The name of the deployable artifact files
        required: false
        default: dist
    outputs:
      result:
        description: The result of the deployment
        value: ${{ jobs.deploy.outputs.outcome }}
jobs:
  deploy:
    runs-on: ubuntu-latest
    outputs:
      outcome: ${{ steps.set-result.outputs.step-result }}
    steps:
      - name: Get Code
        uses: actions/download-artifact@v3
        with:
          name: ${{ inputs.artifact-name }}
      - name: Output information
        run: echo "Deploying & uploading..."
      - name: Set result output
        id: set-result
        run: echo "step-result=success" >> $GITHUB_OUTPUT        

Using the Reusable Workflow:

jobs:
  deploy:
    uses: ./.github/workflows/reusable.yml
    with:
      artifact-name: dist-files
    secrets:
      some-secret: ${{ secrets.some-secret }}
  print-deploy-result:
    needs: deploy
    runs-on: ubuntu-latest
    steps:
      - name: Print deploy output
        run: echo "${{ needs.deploy.outputs.result }}"        

7. Custom GitHub Actions

Custom Actions allow you to simplify complex workflows by combining multiple steps into a single reusable unit. Instead of writing numerous detailed steps in your workflow file, you can build custom actions tailored to solve specific workflow problems, especially when existing community actions don’t meet your needs.

Key Types of Custom Actions:

1. JavaScript Actions:

- These are actions written in Node.js, allowing you to execute JavaScript code and include any npm package you need.

- They are easy to create and use within your workflows, offering flexibility for many common tasks.

- Example: Build a Node.js script to automate a task like testing or deployment without defining each step in your YAML file.

2. Docker Actions:

- Use Docker to create custom actions with specific configurations and dependencies.

- You write a Dockerfile to define the environment and logic, which allows for complex tasks in any language, with the flexibility of Docker.

- Example: Perform multi-step tasks with specialized tools not natively available in GitHub runners.

3. Composite Actions:

- These actions combine multiple steps (using run and uses) in a single action, making them reusable across multiple workflows.

- You can use existing actions and scripts within composite actions, reducing redundancy in your workflows.

- Example: Combine several commonly used steps, such as checking out the code, setting up dependencies, and running tests, into one reusable action.

Custom actions streamline your workflows by packaging logic and functionality into reusable units, giving you greater control over your CI/CD pipeline.


To wrap up the article, here's a practical example of connecting GitHub Actions with Cypress Cloud for running parallel tests across multiple environments. We will cover two use cases: one where all tests are executed in parallel, and another where you can selectively run tests.

Step 1: Create a Reusable Workflow File

Create a new YAML file in your repository under .github/workflows/reusable.yml. This workflow will handle the common steps like checking out the code and installing dependencies.

name: Reusable Setup Workflow

on:
  workflow_call:

jobs:
  setup:
    runs-on: ubuntu-latest
    steps:
      # Step 1: Checkout code
      - name: Checkout ??
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      # Step 2: Setup Node.js and cache Yarn dependencies
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: yarn
          cache-dependency-path: '**/yarn.lock'

      # Step 3: Install Yarn dependencies
      - name: Install dependencies ??
        run: yarn install --frozen-lockfile        

All Tests WorkFlow

In the main workflow, we will reuse the setup workflow and integrate Cypress Cloud to run the tests in parallel on multiple containers.

name: Run All Cypress Tests in Parallel

on:
  push:
    branches:
      - main
  workflow_dispatch:
  schedule:
    - cron: '00 08 * * *'

jobs:
  cypress-tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        container: [1, 2, 3, 4]  # Run across 4 containers
    env:
      CI_ID: ${{github.repository}}-${{github.run_id}}-${{github.run_attempt}}
      CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_CLOUD_RECORD_KEY }}

    steps:
      # Reuse the setup workflow to avoid repetition
      - name: Setup Workflow
        uses: ./.github/workflows/reusable.yml

      # Run Cypress tests in parallel on Chrome
      - name: Run Cypress on Google Chrome ??
        uses: cypress-io/github-action@v6
        with:
          parallel: true
          record: true
          browser: chrome
          group: "parallel-tests"
          ci-build-id: ${{ env.CI_ID }}
          config: retries=1        

Selective Cypress Test Workflow

This GitHub Actions workflow is designed to run Cypress tests selectively based on user input. The user can manually trigger the workflow and select a specific test file or set of tests to run from a predefined list of Cypress specs.

name: Run Selective Cypress Tests

on:
  workflow_dispatch:
    inputs:
      spec:
        description: 'Select which Cypress spec to run'
        required: true
        default: 'cypress/e2e/**/*.cy.js'
        type: choice
        options: 
          - 'cypress/e2e/spec1/**/*.cy.js'
          - 'cypress/e2e/spec2.cy.js'
          - 'cypress/e2e/**/*.cy.js'

jobs:
  selective-tests:
    runs-on: ubuntu-latest
    env:
      CI_ID: ${{github.repository}}-${{github.run_id}}-${{github.run_attempt}}
      CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_CLOUD_RECORD_KEY }}
      CYPRESS_SPEC: ${{ github.event.inputs.spec }}

    steps:
      # Step 1: Reuse the setup workflow
      - name: Setup Workflow
        uses: ./.github/workflows/reusable.yml

      # Step 2: Run specific Cypress tests
      - name: Run Selected Cypress Tests ??
        uses: cypress-io/github-action@v6
        with:
          spec: ${{ env.CYPRESS_SPEC }}
          browser: chrome
          record: true
          ci-build-id: ${{ env.CI_ID }}
          config: retries=1        


ShivaKumar P

Devops Engineer at Tech Mahindra

1 个月

Useful content for beginners ??

Nereu Nogueira

Analista de teste/QA

1 个月

Muito bom

Eduardo Gallindo

Senior Test Automation Engineer | Quality Assurance | Quality Engineer

1 个月

This is essential knowledge for anyone serious about test automation and looking to scale their projects, driving real impact on product development. Congratulations on the great article!

要查看或添加评论,请登录

社区洞察

其他会员也浏览了