Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

424 lines · 19.2 KBRaw History
1---
2title: Reusable workflows
3description: Call one workflow from another with jobs.<id>.uses, pass inputs and secrets, read outputs, and share workflows across repositories.
4---
5
6A 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
10The called workflow declares its inputs, secrets and outputs under `on.workflow_call`:
11
12```yaml
13# .github/workflows/build.yml
14name: Build
15on:
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
32jobs:
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
51The caller uses it as a job:
52
53```yaml
54# .github/workflows/ci.yml
55name: CI
56on: push
57
58jobs:
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
75A 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
89Any 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
94uses: ./.github/workflows/build.yml
95```
96
97The 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
102uses: acme/shared-workflows/.github/workflows/deploy.yml@v2
103```
104
105The 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
107Which 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
114Access 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
116Inside a workflow called from another repository, a local `./` reference resolves in that other repository, at the commit the called workflow came from.
117
118A 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
122Declare 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
131The 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
139Inside the called workflow, read inputs with `${{ inputs.<name> }}`.
140
141## Secrets
142
143The 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
145Pass specific secrets with a mapping. Each value is an expression evaluated against the caller's secrets:
146
147```yaml
148secrets:
149 DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
150 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
151```
152
153Or pass everything the caller has with `inherit`:
154
155```yaml
156secrets: inherit
157```
158
159At 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
161Declare 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
163Secrets 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
167A 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
170on:
171 workflow_call:
172 outputs:
173 image:
174 value: ${{ jobs.publish.outputs.image }}
175```
176
177The 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
192Because `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
196The jobs inside a call get their `GITHUB_TOKEN` permissions like this, as on GitHub:
197
1981. Start from the calling job's effective permissions: its own `permissions:`, or else the calling workflow's, or else the repository default.
1992. 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.
2003. 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
204jobs:
205 deploy:
206 permissions:
207 contents: read
208 id-token: write
209 uses: ./.github/workflows/deploy.yml
210```
211
212Here 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
216A 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
219jobs:
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
229How 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
238The 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
240Ordering 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
248A 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
251jobs.a/b/c/d/e: reusable workflows can be nested at most 4 levels deep
252```
253
254A 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
258A `strategy.matrix` on a job that calls a reusable workflow isn't supported yet. The run fails with:
259
260```text
261jobs.deploy: a matrix on a job that calls a reusable workflow is not supported yet
262```
263
264Put the matrix on the jobs inside the called workflow instead, passing the list as an input:
265
266```yaml
267# caller
268jobs:
269 test:
270 uses: ./.github/workflows/test.yml
271 with:
272 versions: '["20", "22"]'
273
274# .github/workflows/test.yml
275on:
276 workflow_call:
277 inputs:
278 versions:
279 type: string
280 required: true
281jobs:
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
294Or write one calling job per value.
295
296## Example: a shared deploy pipeline
297
298An 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
302name: Deploy
303on:
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
319jobs:
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
342name: Release
343on:
344 push:
345 branches: [main]
346
347jobs:
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
376The `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
392Errors 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
403Errors 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
417Errors 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
424For the full workflow syntax, see [Workflow syntax](/docs/actions/workflow-syntax). For expressions, see [Expressions and contexts](/docs/actions/expressions).