766 words, 4 min read

If you've spent any time maintaining CI/CD pipelines, you've probably run into this situation: a workflow that deploys your app, and some follow-up work that should happen right after — posting a changelog, syncing release notes to a project tracker, notifying a downstream system. The naive solution is to pile everything into the same workflow file. It works, but it doesn't stay clean for long.

A better option is to split the follow-up into its own workflow file and call it from the deployment workflow when needed. GitHub Actions supports this natively through reusable workflows, and once you understand how the wiring works, it's surprisingly straightforward.

GitHub Actions has a trigger type called workflow_call. When you add it to a workflow's on: block, that workflow becomes callable by other workflows in the same repository — similar to importing a function from another module. The calling workflow references it as a job using uses: with a relative path, instead of a runs-on + steps structure.

The pattern looks like this:

The reusable workflow (release-notes.yaml):

on:
workflow_call:
workflow_dispatch: # keep this so you can still trigger it manually

The caller workflow (deploy.yaml):

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
run: ./deploy.sh
post-release-notes:
needs: [deploy]
uses: ./.github/workflows/release-notes.yaml
secrets: inherit

That's the whole thing. When the deploy job finishes, post-release-notes kicks off by running the full release-notes.yaml workflow.

A few things worth knowing

  • Reusable workflows are jobs, not steps. This is a constraint that trips people up. You cannot nest a uses: directive inside a job's steps: block. It has to be a top-level job. If you need to call a reusable workflow conditionally (e.g., only on pushes to main), you do that with an if: on the job itself, not inside the workflow being called.

  • secrets: inherit does what you think. Without it, the called workflow has no access to secrets. You'd have to pass them explicitly via with: inputs, which is verbose. Using secrets: inherit passes the calling workflow's secrets through automatically, which is almost always what you want for internal reuse within a single repo.

  • The called workflow can still have its own triggers. Adding workflow_call doesn't replace whatever triggers were already there. A workflow that was previously only schedule-triggered can become reusable without breaking the scheduled runs. This is handy during a migration — you can add workflow_call first, wire up the caller, and verify everything works before removing any manual triggers.

  • Conditional execution on the caller side. When you have separate deploy jobs for different environments (staging vs. production), you'll often want separate caller jobs too, each with its own needs: and if: condition:

    post-release-notes-staging:
    needs: [deploy-staging]
    if: github.ref == 'refs/heads/develop'
    uses: ./.github/workflows/release-notes.yaml
    secrets: inherit
    post-release-notes-production:
    needs: [deploy-production]
    if: github.ref == 'refs/heads/main'
    uses: ./.github/workflows/release-notes.yaml
    secrets: inherit

Each one runs independently after its respective deploy job, on the appropriate branch.

You could trigger a workflow via the GitHub API with a curl call to the workflow_dispatch endpoint — and this is sometimes the right move when the target workflow lives in a different repository. But within the same repo, it's an awkward fit. You're introducing an async HTTP call where a direct dependency would do, you lose visibility into the triggered run from the calling workflow's summary page, and you have to handle auth tokens explicitly.

With workflow_call, the called workflow appears as a regular job in the same run. It shows up in the same Actions UI view, its logs are right there, and failures propagate naturally without any custom error handling.

The main practical benefit of this approach isn't just reusability — it's that it lets you keep each workflow file focused on one thing. A deployment workflow shouldn't have to know about Jira, or Slack, or whatever notification system you're using. That logic belongs in its own file, tested and maintained separately, triggered wherever it's needed.

Reusable workflows are one of the more underused features of GitHub Actions. The documentation covers them, but the examples tend to stop at "here's the syntax." In practice, the pattern of splitting post-deployment tasks into callable workflows — and composing them from the main CI/CD pipeline — scales well as pipelines grow and keeps things readable as teams expand.

If your deployment workflow is getting long, it's worth looking at what could be pulled out into a reusable workflow. Chances are, more of it is portable than you'd expect.