Mastering Reusable Workflows in GitHub Actions

Mastering Reusable Workflows in GitHub Actions

Published on
Authors

In the world of DevOps, efficiency comes from not reinventing the wheel. If you’ve used Jenkins, you’re probably familiar with shared libraries—those handy collections of reusable pipeline code that teams can pull into their jobs to standardize processes and reduce duplication. GitHub Actions offers something similar through reusable workflows. These allow you to define a workflow once in a YAML file and call it from multiple other workflows across repositories. This promotes consistency, makes maintenance easier, and speeds up development.

In this lengthy blog post, we’ll explore reusable workflows in depth. We’ll start with the basics, walk through the process of creating and invoking them, discuss mandatory and non-mandatory parameters, and provide sample code. We’ll also cover advanced topics like using a central repository for all your workflows and how to reference them from other repos. By the end, you’ll have everything you need to implement this in your projects, complete with an example graph in Markdown to visualize the flow.

Why Use Reusable Workflows? The Jenkins Analogy

In Jenkins, shared libraries let you store common pipeline steps, like building a Docker image or running security scans, in a central place. You load them into your Jenkinsfile with a simple directive, passing parameters as needed. This keeps your pipelines DRY (Don’t Repeat Yourself) and ensures everyone follows best practices.

GitHub Actions reusable workflows work similarly but are native to GitHub’s ecosystem. Instead of scripting in Groovy, you use YAML. A reusable workflow is a standalone YAML file that other workflows can “call” like a function. It’s triggered by the workflow_call event, and you can pass inputs, secrets, and even get outputs back. This is perfect for standardizing CI/CD steps across teams or projects.

Benefits include:

  • Reduced Duplication: Write once, use everywhere.
  • Easier Updates: Change the reusable workflow, and all callers benefit.
  • Better Security: Centralize sensitive logic, like secret handling.
  • Scalability: Ideal for monorepos or multi-repo setups in organizations.

However, there are limits: You can’t nest more than four levels of workflows, and reusable files must be in the .github/workflows directory—no subfolders allowed. Also, callers and callees must be in the same organization for private repos, unless you adjust permissions.

The Process: Creating a Reusable Workflow

Creating a reusable workflow is straightforward. Follow these steps:

  1. Set Up a Repository: Create or use an existing GitHub repo. Place your workflow YAML in .github/workflows/. For shared use, dedicate a repo just for workflows (more on this later).

  2. Define the Workflow: Start with the on trigger set to workflow_call. This makes it callable.

  3. Add Inputs and Secrets: Inputs are like parameters—mandatory (required: true) or optional (required: false). Types can be string, number, or boolean. Secrets are passed securely and are often mandatory for auth tasks.

  4. Define Jobs and Steps: Build your logic inside jobs, just like a regular workflow. Reference inputs with ${{ inputs.name }}.

  5. Handle Outputs (Optional): If the workflow produces data for the caller, define outputs at the workflow level, mapping from job outputs.

  6. Commit and Push: Once saved, it’s ready to be called.

Best practices: Keep workflows focused on one task, document inputs/outputs in the YAML, and use descriptive names.

Sample Code: A Reusable Workflow for Building and Testing

Here’s a sample reusable workflow called build-test.yml. It handles building a Node.js app, running tests, and outputting coverage stats. It has mandatory inputs (like repo path) and optional ones (like Node version).

name: Reusable Build and Test

on:
  workflow_call:
    inputs:
      repo-path:
        description: 'Path to the repository root'
        required: true
        type: string
        default: '.'
      node-version:
        description: 'Node.js version to use'
        required: false
        type: string
        default: '20'
      run-tests:
        description: 'Whether to run tests'
        required: false
        type: boolean
        default: true
    secrets:
      npm-token:
        description: 'NPM auth token for private packages'
        required: false
    outputs:
      coverage:
        description: 'Test coverage percentage'
        value: ${{ jobs.build-test.outputs.coverage }}

jobs:
  build-test:
    runs-on: ubuntu-latest
    outputs:
      coverage: ${{ steps.test.outputs.coverage }}
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          path: ${{ inputs.repo-path }}

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          registry-url: 'https://registry.npmjs.org'

      - name: Install Dependencies
        run: npm ci
        working-directory: ${{ inputs.repo-path }}
        env:
          NODE_AUTH_TOKEN: ${{ secrets.npm-token }}

      - name: Build Application
        run: npm run build
        working-directory: ${{ inputs.repo-path }}

      - name: Run Tests
        id: test
        if: ${{ inputs.run-tests }}
        run: |
          npm test -- --coverage
          COVERAGE=$(cat coverage/lcov-report/index.html | grep -oP '(?<=<span class="strong">)d+.d+(?=% <span class="quiet">Lines</span>)')
          echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT
        working-directory: ${{ inputs.repo-path }}

In this example:

  • Mandatory Parameter: repo-path must be provided; it has required: true.
  • Non-Mandatory Parameters: node-version and run-tests have defaults, so they’re optional. npm-token secret is optional too.
  • Output: coverage is produced if tests run, for use in the caller.

Invoking the Reusable Workflow: The Caller Side

To invoke (call) a reusable workflow:

  1. Create a Caller Workflow: In your project’s .github/workflows/ directory, make a new YAML file, like ci-pipeline.yml.

  2. Trigger It Normally: Use standard triggers like push or pull_request.

  3. Call the Reusable One: In a job, use uses: {owner}/{repo}/.github/workflows/{filename}@{ref}. The ref can be a branch (e.g., main), tag (e.g., v1), or SHA.

  4. Pass Parameters: Under with:, provide inputs. For secrets, use secrets: or secrets: inherit to pass all from the caller.

  5. Use Outputs: Reference them with ${{ needs.job-name.outputs.name }}.

  6. Handle Permissions: Ensure the caller has read access to the reusable repo. For private repos, use organization-level sharing.

Invoking triggers the reusable workflow as part of the caller’s execution. If it fails, the caller fails too.

Sample Code: A Caller Workflow Invoking the Reusable One

Here’s ci-pipeline.yml in your app repo, calling the above reusable workflow. It also includes a deploy job that uses the output.

name: CI Pipeline with Reusable Workflow

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

jobs:
  build-and-test:
    uses: your-org/shared-workflows/.github/workflows/build-test.yml@main
    with:
      repo-path: '.' # Mandatory input
      node-version: '18' # Optional, overriding default
      run-tests: true # Optional
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }} # Optional secret

  deploy:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: ${{ needs.build-and-test.outputs.coverage > 80 }} # Using output
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Deploy to Production
        run: echo "Deploying with coverage ${{ needs.build-and-test.outputs.coverage }}%"
        # Add actual deploy steps here

To invoke this pipeline:

  • Push code to the main branch or open a PR—it runs automatically.
  • Manually trigger via GitHub UI with workflow_dispatch if added to on.
  • From CLI: Use gh workflow run ci-pipeline.yml with GitHub CLI.

If using secrets: inherit, the caller passes all its secrets without listing them—great for simplicity, but be cautious with exposure.

Mandatory vs. Non-Mandatory Parameters

  • Mandatory (Required): Set required: true in the reusable workflow. The caller must provide these, or the workflow fails. Example: A build path that’s essential.
  • Non-Mandatory (Optional): required: false, often with a default value. Callers can omit or override. Example: A debug flag defaulting to false.

Always document them with description for clarity. Types enforce data correctness—e.g., a boolean input rejects strings.

Using a Central Repository for All Workflows

Yes, it’s common to have a dedicated repository (e.g., your-org/shared-workflows) housing all reusable workflows in one .github/workflows/ folder. This acts like a “library” repo, similar to Jenkins’ global shared libraries.

How it works:

  • Setup: Create the repo, add YAML files like build-test.yml, deploy.yml, etc., all in .github/workflows/. No subfolders—GitHub doesn’t support them for workflows.
  • Versioning: Use tags (e.g., v1.0) for stable versions. Callers reference specific refs to avoid breaking changes.
  • Permissions: Make it private for orgs, or public for open-source. Ensure calling repos have access via org membership or PATs.
  • Invoking from Your Repo: In your app’s workflow, use uses: your-org/shared-workflows/.github/workflows/build-test.yml@v1.0. This pulls from the central repo at runtime.
  • Updates: Push changes to the central repo. Callers auto-use the new version if referencing a branch like main, or update the ref manually for tags.
  • Best Practices: Include a README in the central repo documenting each workflow’s inputs/outputs. Use semantic versioning for tags.

This central approach is ideal for enterprises with many teams. For example, repos like GitHub’s own actions/reusable-workflows provide templates for action development.

Advanced Topics: Matrix Strategies, Nesting, and Limitations

  • Matrix Strategies: Reusable workflows support matrices for parallel runs (e.g., testing multiple OS). Define in the caller or reusable.
  • Nesting: Call one reusable from another, up to 4 levels deep.
  • Limitations: No environment variables in workflow_call; max 100 reusable calls per workflow; private reusables require same org.
  • Error Handling: If a reusable fails, debug via GitHub’s action logs. Use continue-on-error in callers for non-critical calls.

Visualizing the Flow: An Example Graph

Here’s a Markdown graph showing a caller workflow invoking multiple reusables from a central repo, with parameters and outputs.

+-------------------+     +-----------------------------+
|   Caller Repo     |     |   Central Shared Repo       |
| - ci-pipeline.yml | --> | - .github/workflows/        |
|   (Your App)      |     |   - build-test.yml          |
+-------------------+     |   - deploy.yml              |
                          +-----------------------------+
                                       |
                                       v
+-------------------+     +-------------------+     +-------------------+
|   Triggers        | --> |   Call Reusable   | --> |   Pass Parameters |
| - Push/PR         |     | - uses: ...@ref   |     | - Mandatory: path |
| - Workflow Dispatch|    |                   |     | - Optional: version|
+-------------------+     +-------------------+     | - Secrets: token  |
                                                             |
                                                             v
+-------------------+     +-------------------+     +-------------------+
|   Execute Jobs    | --> |   Handle Outputs  | --> |   Next Steps      |
| - Build & Test    |     | - Coverage %      |     | - Deploy if OK    |
| - Reference Inputs|     |                   |     | - Rollback on Fail|
+-------------------+     +-------------------+     +-------------------+
                                       |
                                       v
                            +-------------------+
                            |   Environments    |
                            | - Dev/Staging/Prod|
                            +-------------------+

This graph illustrates the flow: Triggers in the caller invoke reusables from the central repo, passing params, executing logic, and using outputs for decisions.

Conclusion: Elevate Your GitHub Actions with Reusables

Reusable workflows bring the power of shared libraries to GitHub Actions, making your automation modular and maintainable. By creating them with clear parameters, invoking via uses, and centralizing in a dedicated repo, you streamline processes across projects. Start with a simple one, like the build-test example, and expand. Experiment in a test repo—GitHub’s free tier makes it easy. With these tools, your team can focus on innovation, not repetition. Happy automating!

Cheers,

Sim