Diffinery
Actions: Reusable workflows
Docs / Actions

Reusable workflows

Call one workflow from another with jobs.<id>.uses, pass inputs and secrets, read outputs, and share workflows across repositories.

A reusable workflow is a workflow file with an on: workflow_call trigger that other workflows call as a job, with jobs.<id>.uses. Use one to keep a build, test or deploy pipeline in one place and call it from many workflows or repositories. Diffinery implements GitHub's syntax and behaviour; the differences are listed in Differences from GitHub.

A minimal example

The called workflow declares its inputs, secrets and outputs under on.workflow_call:

# .github/workflows/build.yml
name: Build
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: "22"
      run-e2e:
        type: boolean
        default: false
    secrets:
      NPM_TOKEN:
        required: true
    outputs:
      version:
        description: The version that was built
        value: ${{ jobs.build.outputs.version }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      - id: meta
        run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
      - if: ${{ inputs.run-e2e }}
        run: npm run test:e2e

The caller uses it as a job:

# .github/workflows/ci.yml
name: CI
on: push

jobs:
  build:
    uses: ./.github/workflows/build.yml
    with:
      run-e2e: true
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

  report:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Built version ${{ needs.build.outputs.version }}"

Calling a workflow

A calling job has uses instead of runs-on and steps. It accepts these keys:

KeyDescription
usesThe workflow to call (required). See the formats below.
withValues for the called workflow's inputs.
secretsA mapping of secrets, or inherit.
needsJobs to wait for, as for any job.
ifSkip the call (and every job inside it) when false.
nameDisplay name, used as the prefix of the jobs inside the call.
strategyAccepted, but a matrix isn't supported yet (see Matrix on the calling job).
permissionsThe most the jobs inside the call may have. They inherit these permissions, and anything the called workflow declares is lowered to them. See Permissions in called workflows.
concurrencyAccepted but has no effect on a calling job. Jobs inside the called workflow can use their own job-level concurrency.

Any other key produces a warning (unknown reusable-workflow job key '...') and is ignored. That includes environment: as on GitHub, a calling job can't use an environment, so put environment: on the jobs inside the called workflow (see Environments in called workflows). steps or runs-on on a calling job is an error.

Local workflows

uses: ./.github/workflows/build.yml

The path starts with ./, is relative to the repository root, names a file in .github/workflows/ or .diffinery/workflows/ and ends in .yml or .yaml. The file is read from the same commit as the calling workflow, so a branch or pull request can change both together. Workflows under .diffinery/workflows/ can be called the same way (./.diffinery/workflows/build.yml).

Workflows in other repositories

uses: acme/shared-workflows/.github/workflows/deploy.yml@v2

The format is <owner>/<repo>/<path>@<ref>, where <ref> is a branch, a tag or a commit SHA, and the repository is on the same Diffinery instance (here, diffinery.com). A workflow on github.com can't be called; copy it into a Diffinery repository first.

Which repositories you can call:

Called repositoryCan be called from
PublicAny repository on Diffinery
PrivateOnly repositories with the same owner (the same user or organization)

Access is decided by the two repositories' owners, not by who pushed the commit. A call that isn't allowed fails with repository acme/shared-workflows not found, or its workflows are not shared with acme/website.

Inside a workflow called from another repository, a local ./ reference resolves in that other repository, at the commit the called workflow came from.

A branch or tag <ref> is resolved once, when the run is planned: every job of the run, including one that waits for an environment's approval, uses the called workflow from that commit, even if the branch moves during the run.

Inputs

Declare inputs under on.workflow_call.inputs. Each input must have a type:

KeyDescription
typestring, boolean or number (required).
requiredtrue to make the call fail when the input is missing or empty.
defaultValue used when the caller doesn't pass one. Expressions are allowed.
descriptionFree text.

The caller passes values with with. Rules:

  • Every key in with must be a declared input. An unknown key fails the run with jobs.<id>.with.<key>: <uses> has no input named '<key>'.
  • with values can use expressions with the github, needs (the calling job's needs), inputs (when the caller is itself inside a reusable workflow) and vars contexts.
  • Values are converted to the declared type: a boolean input is true for true or "true"; a number input goes through Number().
  • A missing optional input with no default is "", false or 0 depending on its type.
  • A missing required input fails the jobs inside the call with jobs.<id>: input '<name>' is required by <uses>.

Inside the called workflow, read inputs with ${{ inputs.<name> }}.

Secrets

The called workflow only sees the secrets the caller passes, plus GITHUB_TOKEN, which is always the job's own token, plus, in a job with environment:, that environment's secrets (see Environments in called workflows).

Pass specific secrets with a mapping. Each value is an expression evaluated against the caller's secrets:

secrets:
  DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
  NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Or pass everything the caller has with inherit:

secrets: inherit

At the top level, "everything the caller has" is the calling repository's secrets plus its organization's secrets. In a nested call, it's whatever the enclosing call received.

Declare the secrets a workflow needs under on.workflow_call.secrets. A secret declared with required: true that the caller doesn't provide fails the jobs inside the call with jobs.<id>: secret '<NAME>' is required by <uses>. Secret names are case-insensitive and are stored uppercase.

Secrets for a cross-repository call always come from the calling repository and its organization, never from the repository that holds the workflow.

Outputs

A called workflow exposes results through on.workflow_call.outputs. Each output has a value, an expression that can read the jobs context of the called workflow (jobs.<job id>.outputs.<name> and jobs.<job id>.result) and its inputs:

on:
  workflow_call:
    outputs:
      image:
        value: ${{ jobs.publish.outputs.image }}

The caller reads it as ${{ needs.<calling job id>.outputs.image }}. Values are strings; objects are JSON-encoded. Only declared outputs are visible to the caller.

needs.<calling job id>.result summarizes the jobs inside the call: failure if any failed or timed out, otherwise cancelled if any was cancelled, skipped if all were skipped, and success otherwise.

Contexts inside a called workflow

ContextValue inside the called workflow
githubThe caller's run: the same event, ref, sha, repository and actor. github.job is the job's own key inside the called workflow.
inputsThe resolved inputs.
secretsThe passed secrets and GITHUB_TOKEN, plus the environment's secrets in a job with environment:.
varsThe calling repository's variables, plus the environment's variables in a job with environment:.
envThe called workflow's own env (workflow and job level). The caller's env is not passed.
needsJob ids as written inside the called workflow.

Because github.repository is the caller's, actions/checkout with no inputs checks out the calling repository, as on GitHub. To check out the repository that holds the workflow, pass repository:. The job's GITHUB_TOKEN can read that repository when it's public; when it's private, also pass a token: from a secret.

Permissions in called workflows

The jobs inside a call get their GITHUB_TOKEN permissions like this, as on GitHub:

  1. Start from the calling job's effective permissions: its own permissions:, or else the calling workflow's, or else the repository default.
  2. If the called workflow or the called job declares permissions:, use that instead, but lowered scope by scope to what the calling job has. A called workflow can never get more than its caller.
  3. If neither declares anything, the called jobs get exactly the calling job's permissions.
# .github/workflows/release.yml in acme/website
jobs:
  deploy:
    permissions:
      contents: read
      id-token: write
    uses: ./.github/workflows/deploy.yml

Here the jobs in deploy.yml can have at most contents: read and id-token: write. If deploy.yml declares permissions: write-all, its jobs still get only those two. If it declares packages: write, its jobs get no packages access, because the calling job has none. This applies to id-token for OIDC, to packages for the container registry, and to every other scope. See GITHUB_TOKEN permissions.

Environments in called workflows

A job inside a called workflow can deploy to an environment with environment:, and the environment's name and url can use the called workflow's inputs:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: ${{ inputs.environment }}
      url: https://${{ inputs.environment }}.example.com
    steps:
      - run: ./deploy.sh

How it works:

  • The environment belongs to the calling repository, the one whose run it is, even when the workflow file lives in another repository. Its branch policy is checked against the caller's ref.
  • The job gets the environment's secrets and variables without the caller passing them in secrets:, as on GitHub. An environment secret replaces a passed secret with the same name.
  • A secret declared with required: true under on.workflow_call.secrets must still be passed by the caller, even if the environment has it. Don't declare secrets that come from the environment.
  • The job records a deployment like any other job with environment:, and the usual rules for who receives environment secrets apply (pull request refs never do; runs whose code came from an agent only from environments set to protected branches only).

How calls appear in runs and checks

The called workflow's jobs become jobs of the caller's run. A calling job build that calls a workflow with jobs compile and test produces the jobs build / compile and build / test (the calling job's name, if it has one, replaces build). Checks on the commit are named <workflow name> / <calling job> / <job>.

Ordering follows GitHub:

  • Jobs inside the call that have no needs wait for the calling job's needs.
  • A job that needs the calling job waits for every job inside the call.
  • If the calling job's if is false, or (with the default if) any job it depends on, directly or through other jobs, failed or was skipped, every job inside the call is reported as skipped.

Nesting

A called workflow can itself call reusable workflows, up to four levels below the top-level workflow, as on GitHub. A fifth level fails the run with:

jobs.a/b/c/d/e: reusable workflows can be nested at most 4 levels deep

A run can call at most 20 reusable workflows in total (counting every call at every level); more fails with a run can call at most 20 reusable workflows.

Matrix on the calling job

A strategy.matrix on a job that calls a reusable workflow isn't supported yet. The run fails with:

jobs.deploy: a matrix on a job that calls a reusable workflow is not supported yet

Put the matrix on the jobs inside the called workflow instead, passing the list as an input:

# caller
jobs:
  test:
    uses: ./.github/workflows/test.yml
    with:
      versions: '["20", "22"]'

# .github/workflows/test.yml
on:
  workflow_call:
    inputs:
      versions:
        type: string
        required: true
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: ${{ fromJSON(inputs.versions) }}
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: node --version

Or write one calling job per value.

Example: a shared deploy pipeline

An organization keeps its deploy workflow in acme/shared-workflows (private) and calls it from acme/website (same owner, so allowed):

# acme/shared-workflows: .github/workflows/deploy.yml
name: Deploy
on:
  workflow_call:
    inputs:
      environment:
        type: string
        required: true
      dry-run:
        type: boolean
        default: false
    secrets:
      CLOUDFLARE_API_TOKEN:
        required: true
    outputs:
      url:
        value: ${{ jobs.deploy.outputs.url }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}   # acme/website's staging or production environment
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v4          # checks out acme/website, the caller
      - run: npm ci && npm run build
      - id: deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
        run: |
          if [ "${{ inputs.dry-run }}" = "true" ]; then
            npx wrangler deploy --dry-run
          else
            npx wrangler deploy --env "${{ inputs.environment }}"
          fi
          echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT"
# acme/website: .github/workflows/release.yml
name: Release
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  staging:
    needs: test
    uses: acme/shared-workflows/.github/workflows/deploy.yml@v2
    with:
      environment: staging
    secrets: inherit

  production:
    needs: staging
    uses: acme/shared-workflows/.github/workflows/deploy.yml@v2
    with:
      environment: production
    secrets:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

  announce:
    needs: production
    runs-on: ubuntu-latest
    steps:
      - run: echo "Live at ${{ needs.production.outputs.url }}"

The deploy job uses acme/website's staging and production environments, so each call is checked against that environment's branch policy and records a deployment there. If those environments have their own CLOUDFLARE_API_TOKEN secret, it replaces the one the caller passes.

Differences from GitHub

AreaDiffinery
Where workflows come fromOnly repositories on the same Diffinery instance.
Private repositoriesCallable only from repositories with the same owner.
Matrix on the calling jobNot supported yet.
concurrencyThe top-level workflow's concurrency and job-level concurrency on the jobs inside a call apply. A called workflow's top-level concurrency and a calling job's concurrency are ignored.
Number of calls per runAt most 20.
OIDC job_workflow_ref claimThe top-level workflow's reference, not the called workflow's. See OpenID Connect (OIDC).
Branch referencesRe-resolved whenever a job of the run is planned or started.

Errors

Errors in the calling workflow's syntax make the workflow file invalid. On push, Diffinery records a failed run titled "Invalid workflow file: ..."; the workflow list in the API reports it as invalid.

MessageFix
reusable workflow reference '...' must be './path/to/workflow.yml' or 'owner/repo/path/to/workflow.yml@ref'Fix the uses value. Cross-repository references need @<ref>; local ones need ./.
'./...' must be a file in ./.github/workflows or ./.diffinery/workflowsMove the called workflow into a workflow directory. Files there count as workflow changes, which agents can't merge on their own and job tokens can't push.
a job that calls a reusable workflow ('uses') cannot have 'steps' (or 'runs-on')Move the steps into the called workflow.
input '<name>' must declare a 'type'Add type: string, boolean or number to the input under on.workflow_call.inputs.
input '<name>': invalid type '<t>' (expected one of string, boolean, number)choice and environment exist only for workflow_dispatch.
workflow_call output '<name>' must be a mapping with a 'value'Give each output a value: expression.

Errors found while loading the called workflows fail the run before any job starts. The run shows one failed job, Set up workflow, with the message:

MessageFix
jobs.<id>.uses: <uses> not foundThe file doesn't exist at that path and ref.
jobs.<id>.uses: unknown ref <ref> in <owner>/<repo>The branch, tag or SHA doesn't exist in the called repository.
jobs.<id>.uses: repository <owner>/<repo> not found, or its workflows are not shared with <owner>/<repo>The repository doesn't exist, or it's private and has a different owner.
jobs.<id>.uses: <uses> is not a reusable workflow (it has no 'on: workflow_call' trigger)Add workflow_call to the called workflow's on.
jobs.<id>.uses: <uses> is invalid: ...The called workflow has a syntax error; the details follow.
jobs.<id>.with.<key>: <uses> has no input named '<key>'Remove the key from with, or declare the input.
jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yetMove the matrix into the called workflow.
jobs.<id>: reusable workflows can be nested at most 4 levels deepFlatten the chain of calls.
a run can call at most 20 reusable workflowsCombine calls.

Errors in the values passed fail the jobs inside the call when they're about to start:

MessageFix
jobs.<id>: input '<name>' is required by <uses>Pass the input with with, or give it a default.
jobs.<id>: secret '<NAME>' is required by <uses>Pass the secret in secrets, use secrets: inherit, or create the secret in the calling repository or its organization.

For the full workflow syntax, see Workflow syntax. For expressions, see Expressions and contexts.