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"
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,numberorboolean) 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 }}"
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 setrun-lint, so it uses its default,true. -
secrets: inheritpasses 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
@mainis easy, but any change tomainhits every repo right away, even a broken one. - A tag like
@v1lets 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
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:
-
A calling job has no
steps. A job withuses:can only have a short list of keys, likename,with,secrets,needs,if,strategy,concurrencyandpermissions. Addstepsand the file is invalid. If you need extra steps, put them in a separate job, liketell-meabove. -
envdoes not cross over. Environment variables set at the top of the caller are not passed to the called workflow. Useinputsinstead, orvarsset at the repo or organization level. -
The file must be in
.github/workflows/. Sub-folders do not work. - 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.
- 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.
-
Token permissions can only go down. The called workflow can make the
GITHUB_TOKENweaker 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
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_callto make a workflow callable. - Pass settings with
inputs, secrets withsecrets, and read results withoutputs. - 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.
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)