| 1 | --- |
| 2 | title: Reusable workflows |
| 3 | description: Call one workflow from another with jobs.<id>.uses, pass inputs and secrets, read outputs, and share workflows across repositories. |
| 4 | --- |
| 5 | |
| 6 | 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](#differences-from-github). |
| 7 | |
| 8 | ## A minimal example |
| 9 | |
| 10 | The called workflow declares its inputs, secrets and outputs under `on.workflow_call`: |
| 11 | |
| 12 | ```yaml |
| 13 | # .github/workflows/build.yml |
| 14 | name: Build |
| 15 | on: |
| 16 | workflow_call: |
| 17 | inputs: |
| 18 | node-version: |
| 19 | type: string |
| 20 | default: "22" |
| 21 | run-e2e: |
| 22 | type: boolean |
| 23 | default: false |
| 24 | secrets: |
| 25 | NPM_TOKEN: |
| 26 | required: true |
| 27 | outputs: |
| 28 | version: |
| 29 | description: The version that was built |
| 30 | value: ${{ jobs.build.outputs.version }} |
| 31 | |
| 32 | jobs: |
| 33 | build: |
| 34 | runs-on: ubuntu-latest |
| 35 | outputs: |
| 36 | version: ${{ steps.meta.outputs.version }} |
| 37 | steps: |
| 38 | - uses: actions/checkout@v4 |
| 39 | - uses: actions/setup-node@v4 |
| 40 | with: |
| 41 | node-version: ${{ inputs.node-version }} |
| 42 | - run: npm ci |
| 43 | env: |
| 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} |
| 45 | - id: meta |
| 46 | run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT" |
| 47 | - if: ${{ inputs.run-e2e }} |
| 48 | run: npm run test:e2e |
| 49 | ``` |
| 50 | |
| 51 | The caller uses it as a job: |
| 52 | |
| 53 | ```yaml |
| 54 | # .github/workflows/ci.yml |
| 55 | name: CI |
| 56 | on: push |
| 57 | |
| 58 | jobs: |
| 59 | build: |
| 60 | uses: ./.github/workflows/build.yml |
| 61 | with: |
| 62 | run-e2e: true |
| 63 | secrets: |
| 64 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} |
| 65 | |
| 66 | report: |
| 67 | needs: build |
| 68 | runs-on: ubuntu-latest |
| 69 | steps: |
| 70 | - run: echo "Built version ${{ needs.build.outputs.version }}" |
| 71 | ``` |
| 72 | |
| 73 | ## Calling a workflow |
| 74 | |
| 75 | A calling job has `uses` instead of `runs-on` and `steps`. It accepts these keys: |
| 76 | |
| 77 | | Key | Description | |
| 78 | |---|---| |
| 79 | | `uses` | The workflow to call (required). See the formats below. | |
| 80 | | `with` | Values for the called workflow's inputs. | |
| 81 | | `secrets` | A mapping of secrets, or `inherit`. | |
| 82 | | `needs` | Jobs to wait for, as for any job. | |
| 83 | | `if` | Skip the call (and every job inside it) when false. | |
| 84 | | `name` | Display name, used as the prefix of the jobs inside the call. | |
| 85 | | `strategy` | Accepted, but a `matrix` isn't supported yet (see [Matrix on the calling job](#matrix-on-the-calling-job)). | |
| 86 | | `permissions` | The 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](#permissions-in-called-workflows). | |
| 87 | | `concurrency` | Accepted but has no effect on a calling job. Jobs inside the called workflow can use their own job-level `concurrency`. | |
| 88 | |
| 89 | 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](#environments-in-called-workflows)). `steps` or `runs-on` on a calling job is an error. |
| 90 | |
| 91 | ### Local workflows |
| 92 | |
| 93 | ```yaml |
| 94 | uses: ./.github/workflows/build.yml |
| 95 | ``` |
| 96 | |
| 97 | 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`). |
| 98 | |
| 99 | ### Workflows in other repositories |
| 100 | |
| 101 | ```yaml |
| 102 | uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 |
| 103 | ``` |
| 104 | |
| 105 | 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. |
| 106 | |
| 107 | Which repositories you can call: |
| 108 | |
| 109 | | Called repository | Can be called from | |
| 110 | |---|---| |
| 111 | | Public | Any repository on Diffinery | |
| 112 | | Private | Only repositories with the **same owner** (the same user or organization) | |
| 113 | |
| 114 | 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`. |
| 115 | |
| 116 | Inside a workflow called from another repository, a local `./` reference resolves in that other repository, at the commit the called workflow came from. |
| 117 | |
| 118 | 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. |
| 119 | |
| 120 | ## Inputs |
| 121 | |
| 122 | Declare inputs under `on.workflow_call.inputs`. Each input must have a `type`: |
| 123 | |
| 124 | | Key | Description | |
| 125 | |---|---| |
| 126 | | `type` | `string`, `boolean` or `number` (required). | |
| 127 | | `required` | `true` to make the call fail when the input is missing or empty. | |
| 128 | | `default` | Value used when the caller doesn't pass one. Expressions are allowed. | |
| 129 | | `description` | Free text. | |
| 130 | |
| 131 | The caller passes values with `with`. Rules: |
| 132 | |
| 133 | - 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>'`. |
| 134 | - `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. |
| 135 | - Values are converted to the declared type: a `boolean` input is true for `true` or `"true"`; a `number` input goes through `Number()`. |
| 136 | - A missing optional input with no default is `""`, `false` or `0` depending on its type. |
| 137 | - A missing required input fails the jobs inside the call with `jobs.<id>: input '<name>' is required by <uses>`. |
| 138 | |
| 139 | Inside the called workflow, read inputs with `${{ inputs.<name> }}`. |
| 140 | |
| 141 | ## Secrets |
| 142 | |
| 143 | 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](#environments-in-called-workflows)). |
| 144 | |
| 145 | Pass specific secrets with a mapping. Each value is an expression evaluated against the caller's secrets: |
| 146 | |
| 147 | ```yaml |
| 148 | secrets: |
| 149 | DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }} |
| 150 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} |
| 151 | ``` |
| 152 | |
| 153 | Or pass everything the caller has with `inherit`: |
| 154 | |
| 155 | ```yaml |
| 156 | secrets: inherit |
| 157 | ``` |
| 158 | |
| 159 | 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. |
| 160 | |
| 161 | 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. |
| 162 | |
| 163 | Secrets for a cross-repository call always come from the **calling** repository and its organization, never from the repository that holds the workflow. |
| 164 | |
| 165 | ## Outputs |
| 166 | |
| 167 | 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`: |
| 168 | |
| 169 | ```yaml |
| 170 | on: |
| 171 | workflow_call: |
| 172 | outputs: |
| 173 | image: |
| 174 | value: ${{ jobs.publish.outputs.image }} |
| 175 | ``` |
| 176 | |
| 177 | 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. |
| 178 | |
| 179 | `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. |
| 180 | |
| 181 | ## Contexts inside a called workflow |
| 182 | |
| 183 | | Context | Value inside the called workflow | |
| 184 | |---|---| |
| 185 | | `github` | The caller's run: the same event, `ref`, `sha`, `repository` and actor. `github.job` is the job's own key inside the called workflow. | |
| 186 | | `inputs` | The resolved inputs. | |
| 187 | | `secrets` | The passed secrets and `GITHUB_TOKEN`, plus the environment's secrets in a job with `environment:`. | |
| 188 | | `vars` | The calling repository's variables, plus the environment's variables in a job with `environment:`. | |
| 189 | | `env` | The called workflow's own `env` (workflow and job level). The caller's `env` is not passed. | |
| 190 | | `needs` | Job ids as written inside the called workflow. | |
| 191 | |
| 192 | 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. |
| 193 | |
| 194 | ### Permissions in called workflows |
| 195 | |
| 196 | The jobs inside a call get their `GITHUB_TOKEN` permissions like this, as on GitHub: |
| 197 | |
| 198 | 1. Start from the calling job's effective permissions: its own `permissions:`, or else the calling workflow's, or else the repository default. |
| 199 | 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. |
| 200 | 3. If neither declares anything, the called jobs get exactly the calling job's permissions. |
| 201 | |
| 202 | ```yaml |
| 203 | # .github/workflows/release.yml in acme/website |
| 204 | jobs: |
| 205 | deploy: |
| 206 | permissions: |
| 207 | contents: read |
| 208 | id-token: write |
| 209 | uses: ./.github/workflows/deploy.yml |
| 210 | ``` |
| 211 | |
| 212 | 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](/docs/actions/oidc), to `packages` for the [container registry](/docs/packages/container-registry), and to every other scope. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions#reusable-workflows). |
| 213 | |
| 214 | ## Environments in called workflows |
| 215 | |
| 216 | A job inside a called workflow can deploy to an [environment](/docs/actions/environments) with `environment:`, and the environment's name and `url` can use the called workflow's `inputs`: |
| 217 | |
| 218 | ```yaml |
| 219 | jobs: |
| 220 | deploy: |
| 221 | runs-on: ubuntu-latest |
| 222 | environment: |
| 223 | name: ${{ inputs.environment }} |
| 224 | url: https://${{ inputs.environment }}.example.com |
| 225 | steps: |
| 226 | - run: ./deploy.sh |
| 227 | ``` |
| 228 | |
| 229 | How it works: |
| 230 | |
| 231 | - 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. |
| 232 | - 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. |
| 233 | - 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. |
| 234 | - 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). |
| 235 | |
| 236 | ## How calls appear in runs and checks |
| 237 | |
| 238 | 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>`. |
| 239 | |
| 240 | Ordering follows GitHub: |
| 241 | |
| 242 | - Jobs inside the call that have no `needs` wait for the calling job's `needs`. |
| 243 | - A job that `needs` the calling job waits for every job inside the call. |
| 244 | - 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. |
| 245 | |
| 246 | ## Nesting |
| 247 | |
| 248 | 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: |
| 249 | |
| 250 | ```text |
| 251 | jobs.a/b/c/d/e: reusable workflows can be nested at most 4 levels deep |
| 252 | ``` |
| 253 | |
| 254 | 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`. |
| 255 | |
| 256 | ## Matrix on the calling job |
| 257 | |
| 258 | A `strategy.matrix` on a job that calls a reusable workflow isn't supported yet. The run fails with: |
| 259 | |
| 260 | ```text |
| 261 | jobs.deploy: a matrix on a job that calls a reusable workflow is not supported yet |
| 262 | ``` |
| 263 | |
| 264 | Put the matrix on the jobs inside the called workflow instead, passing the list as an input: |
| 265 | |
| 266 | ```yaml |
| 267 | # caller |
| 268 | jobs: |
| 269 | test: |
| 270 | uses: ./.github/workflows/test.yml |
| 271 | with: |
| 272 | versions: '["20", "22"]' |
| 273 | |
| 274 | # .github/workflows/test.yml |
| 275 | on: |
| 276 | workflow_call: |
| 277 | inputs: |
| 278 | versions: |
| 279 | type: string |
| 280 | required: true |
| 281 | jobs: |
| 282 | test: |
| 283 | runs-on: ubuntu-latest |
| 284 | strategy: |
| 285 | matrix: |
| 286 | node: ${{ fromJSON(inputs.versions) }} |
| 287 | steps: |
| 288 | - uses: actions/setup-node@v4 |
| 289 | with: |
| 290 | node-version: ${{ matrix.node }} |
| 291 | - run: node --version |
| 292 | ``` |
| 293 | |
| 294 | Or write one calling job per value. |
| 295 | |
| 296 | ## Example: a shared deploy pipeline |
| 297 | |
| 298 | An organization keeps its deploy workflow in `acme/shared-workflows` (private) and calls it from `acme/website` (same owner, so allowed): |
| 299 | |
| 300 | ```yaml |
| 301 | # acme/shared-workflows: .github/workflows/deploy.yml |
| 302 | name: Deploy |
| 303 | on: |
| 304 | workflow_call: |
| 305 | inputs: |
| 306 | environment: |
| 307 | type: string |
| 308 | required: true |
| 309 | dry-run: |
| 310 | type: boolean |
| 311 | default: false |
| 312 | secrets: |
| 313 | CLOUDFLARE_API_TOKEN: |
| 314 | required: true |
| 315 | outputs: |
| 316 | url: |
| 317 | value: ${{ jobs.deploy.outputs.url }} |
| 318 | |
| 319 | jobs: |
| 320 | deploy: |
| 321 | runs-on: ubuntu-latest |
| 322 | environment: ${{ inputs.environment }} # acme/website's staging or production environment |
| 323 | outputs: |
| 324 | url: ${{ steps.deploy.outputs.url }} |
| 325 | steps: |
| 326 | - uses: actions/checkout@v4 # checks out acme/website, the caller |
| 327 | - run: npm ci && npm run build |
| 328 | - id: deploy |
| 329 | env: |
| 330 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 331 | run: | |
| 332 | if [ "${{ inputs.dry-run }}" = "true" ]; then |
| 333 | npx wrangler deploy --dry-run |
| 334 | else |
| 335 | npx wrangler deploy --env "${{ inputs.environment }}" |
| 336 | fi |
| 337 | echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT" |
| 338 | ``` |
| 339 | |
| 340 | ```yaml |
| 341 | # acme/website: .github/workflows/release.yml |
| 342 | name: Release |
| 343 | on: |
| 344 | push: |
| 345 | branches: [main] |
| 346 | |
| 347 | jobs: |
| 348 | test: |
| 349 | runs-on: ubuntu-latest |
| 350 | steps: |
| 351 | - uses: actions/checkout@v4 |
| 352 | - run: npm ci && npm test |
| 353 | |
| 354 | staging: |
| 355 | needs: test |
| 356 | uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 |
| 357 | with: |
| 358 | environment: staging |
| 359 | secrets: inherit |
| 360 | |
| 361 | production: |
| 362 | needs: staging |
| 363 | uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 |
| 364 | with: |
| 365 | environment: production |
| 366 | secrets: |
| 367 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 368 | |
| 369 | announce: |
| 370 | needs: production |
| 371 | runs-on: ubuntu-latest |
| 372 | steps: |
| 373 | - run: echo "Live at ${{ needs.production.outputs.url }}" |
| 374 | ``` |
| 375 | |
| 376 | 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. |
| 377 | |
| 378 | ## Differences from GitHub |
| 379 | |
| 380 | | Area | Diffinery | |
| 381 | |---|---| |
| 382 | | Where workflows come from | Only repositories on the same Diffinery instance. | |
| 383 | | Private repositories | Callable only from repositories with the same owner. | |
| 384 | | Matrix on the calling job | Not supported yet. | |
| 385 | | `concurrency` | The 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. | |
| 386 | | Number of calls per run | At most 20. | |
| 387 | | OIDC `job_workflow_ref` claim | The top-level workflow's reference, not the called workflow's. See [OpenID Connect (OIDC)](/docs/actions/oidc). | |
| 388 | | Branch references | Re-resolved whenever a job of the run is planned or started. | |
| 389 | |
| 390 | ## Errors |
| 391 | |
| 392 | 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`. |
| 393 | |
| 394 | | Message | Fix | |
| 395 | |---|---| |
| 396 | | `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 `./`. | |
| 397 | | `'./...' must be a file in ./.github/workflows or ./.diffinery/workflows` | Move 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. | |
| 398 | | `a job that calls a reusable workflow ('uses') cannot have 'steps'` (or `'runs-on'`) | Move the steps into the called workflow. | |
| 399 | | `input '<name>' must declare a 'type'` | Add `type: string`, `boolean` or `number` to the input under `on.workflow_call.inputs`. | |
| 400 | | `input '<name>': invalid type '<t>' (expected one of string, boolean, number)` | `choice` and `environment` exist only for `workflow_dispatch`. | |
| 401 | | `workflow_call output '<name>' must be a mapping with a 'value'` | Give each output a `value:` expression. | |
| 402 | |
| 403 | 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: |
| 404 | |
| 405 | | Message | Fix | |
| 406 | |---|---| |
| 407 | | `jobs.<id>.uses: <uses> not found` | The file doesn't exist at that path and ref. | |
| 408 | | `jobs.<id>.uses: unknown ref <ref> in <owner>/<repo>` | The branch, tag or SHA doesn't exist in the called repository. | |
| 409 | | `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. | |
| 410 | | `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`. | |
| 411 | | `jobs.<id>.uses: <uses> is invalid: ...` | The called workflow has a syntax error; the details follow. | |
| 412 | | `jobs.<id>.with.<key>: <uses> has no input named '<key>'` | Remove the key from `with`, or declare the input. | |
| 413 | | `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet` | Move the matrix into the called workflow. | |
| 414 | | `jobs.<id>: reusable workflows can be nested at most 4 levels deep` | Flatten the chain of calls. | |
| 415 | | `a run can call at most 20 reusable workflows` | Combine calls. | |
| 416 | |
| 417 | Errors in the **values passed** fail the jobs inside the call when they're about to start: |
| 418 | |
| 419 | | Message | Fix | |
| 420 | |---|---| |
| 421 | | `jobs.<id>: input '<name>' is required by <uses>` | Pass the input with `with`, or give it a default. | |
| 422 | | `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. | |
| 423 | |
| 424 | For the full workflow syntax, see [Workflow syntax](/docs/actions/workflow-syntax). For expressions, see [Expressions and contexts](/docs/actions/expressions). |