DEV Community

Cover image for GitHub Actions Reusable Workflows: Write Your CI Once, Use It in Every Repo
ByteBridge Talent LLC
ByteBridge Talent LLC

Posted on

GitHub Actions Reusable Workflows: Write Your CI Once, Use It in Every Repo

You have five repositories. Each one has its own CI file. They all do almost the same thing: check out the code, set up Python, install packages, run the linter, run the tests.

Then one day you need to change something, like the Python version. Now you edit five files, open five pull requests, and hope you did not miss one.

GitHub Actions has a fix for this called reusable workflows. You write the CI steps once, in one place. Every repo calls that one file. Change it once, and every repo gets the change.

This post walks through it step by step, with YAML you can copy.

Two words first

  • A workflow is a YAML file in .github/workflows/ that GitHub runs when something happens, like a push.
  • A reusable workflow is a workflow that other workflows can call, like a function.

The file that does the calling is the caller. The file being called is the called workflow.

Step 1: Write the shared workflow

Make a repo just for shared CI, for example my-org/ci-workflows. Add this file at .github/workflows/python-ci.yml:

name: Python CI (reusable)

on:
  workflow_call:
    inputs:
      python-version:
        description: "Python version to test with"
        type: string
        default: "3.13"
      run-lint:
        description: "Run the linter too?"
        type: boolean
        default: true
    secrets:
      PYPI_TOKEN:
        required: false
    outputs:
      tested-version:
        description: "The Python version that was tested"
        value: ${{ jobs.test.outputs.version }}

jobs:
  test:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.report.outputs.version }}
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-python@v6
        with:
          python-version: ${{ inputs.python-version }}
      - run: pip install -r requirements.txt
      - if: ${{ inputs.run-lint }}
        run: ruff check .
      - run: pytest -q
      - id: report
        run: echo "version=${{ inputs.python-version }}" >> "$GITHUB_OUTPUT"
Enter fullscreen mode Exit fullscreen mode

The key line is on: workflow_call. That is what makes the file callable. Under it you list three things:

  • inputs: settings the caller can pass in. Each one has a type (string, number or boolean) and can have a default.
  • secrets: passwords or tokens the workflow may need. Here the token is optional.
  • outputs: values the workflow hands back to the caller when it is done.

One note: this example installs from requirements.txt, so that file must include ruff and pytest.

Step 2: Call it from each repo

In every app repo, the CI file becomes very short:

name: CI

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  tests:
    uses: my-org/ci-workflows/.github/workflows/python-ci.yml@v1
    with:
      python-version: "3.12"
    secrets: inherit

  tell-me:
    needs: tests
    runs-on: ubuntu-latest
    steps:
      - run: echo "Tested on Python ${{ needs.tests.outputs.tested-version }}"
Enter fullscreen mode Exit fullscreen mode

Read the tests job line by line:

  • uses: points to the shared file: owner, repo, path, then @ and a version.
  • with: fills in the inputs. We did not set run-lint, so it uses its default, true.
  • secrets: inherit passes this repo's secrets along. This works when both repos are in the same organization or enterprise. You can also pass secrets one by one instead.

The second job, tell-me, waits for tests and reads its output with needs.tests.outputs.tested-version.

Step 3: Pin a version

The part after @ matters. You can use a branch name, a tag like v1, or a full commit SHA.

  • A branch like @main is easy, but any change to main hits every repo right away, even a broken one.
  • A tag like @v1 lets you test changes first, then move the tag when you are happy.
  • A commit SHA never changes. It is the safest choice when the shared file lives in a repo you do not control.

Calling a workflow in the same repo

If the shared file is in the same repo, use a short path with no @:

name: CI (same repo)

on:
  pull_request:

jobs:
  tests:
    strategy:
      matrix:
        python: ["3.12", "3.13"]
    uses: ./.github/workflows/python-ci.yml
    with:
      python-version: ${{ matrix.python }}
      run-lint: false
Enter fullscreen mode Exit fullscreen mode

The matrix runs the shared workflow two times, once for each Python version.

Rules that trip people up

These all come from GitHub's own docs:

  1. A calling job has no steps. A job with uses: can only have a short list of keys, like name, with, secrets, needs, if, strategy, concurrency and permissions. Add steps and the file is invalid. If you need extra steps, put them in a separate job, like tell-me above.
  2. env does not cross over. Environment variables set at the top of the caller are not passed to the called workflow. Use inputs instead, or vars set at the repo or organization level.
  3. The file must be in .github/workflows/. Sub-folders do not work.
  4. Access rules apply. A public repo can only call reusable workflows from public repos. A private repo can call public ones, or private ones that have been shared with it in Settings.
  5. There are limits. You can nest up to ten levels of workflows, and one workflow file can call at most 50 unique reusable workflows in total.
  6. Token permissions can only go down. The called workflow can make the GITHUB_TOKEN weaker than the caller's, never stronger.

Check your files before you push

Run actionlint, a free checker for workflow files. It catches typos, wrong input types and the steps mistake from rule 1. All the YAML in this post passes it with no errors.

actionlint
Enter fullscreen mode Exit fullscreen mode

Without it, you often only find a mistake after you push and the run fails.

When to use this, and when not to

Reusable workflows are a great fit when several repos run the same kind of job: test a Python package, build a container, deploy to the same cloud.

They are less useful when each repo is truly different. Forcing very different projects into one shared file gives you a giant file full of if: checks. In that case, a small composite action for the shared steps is often cleaner.

Quick recap

  • Add on: workflow_call to make a workflow callable.
  • Pass settings with inputs, secrets with secrets, and read results with outputs.
  • Call it with uses: at the job level, never inside steps.
  • Pin to a tag or a SHA, not a moving branch.
  • Lint with actionlint before you push.

Source: GitHub Docs: Reusing workflow configurations

If you landed here while reading about ByteBridge Talent LLC IT staffing, this is the kind of everyday clean-up our engineers write about: small changes that make a team's work calmer.

Reusable GitHub Actions workflow: five app repos each call one shared python-ci.yml with uses and an @v1 tag, pass inputs and secrets, and get an output back


ByteBridge Talent LLC is a Grand Island, Nebraska team working on software development, cloud and DevOps, and data. Read more about us at bytebridgetalent.com.

Top comments (0)