call-templates
Template the plumbing of reusable-workflow call jobs and share it across jobs with extends.
job-defaults and executors cover normal jobs, but they deliberately skip the
plumbing of reusable-workflow call jobs — uses:, with:, needs:, and
secrets: must be restated on every job. In matrix-heavy repos that plumbing is
the single biggest source of copy-paste: e.g. vercel/next.js has ~8 nearly
byte-identical test-* call jobs that differ only in one with.afterBuild value.
call-templates defines a named call-job preset; a job extends: it and overrides
only the deltas. Actio merges the template at compile time and strips both
call-templates and extends from the output — the generated YAML is byte-identical
to the hand-written call jobs.
Each job inherits uses, needs, secrets, and the shared with keys; only the
per-job afterBuild differs:
name: Build and test
on: [push]
call-templates:
test:
uses: ./.github/workflows/test_reusable.yml
needs: build
with:
testTimingsArtifact: test-timings
secrets: inherit
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo build
test-unit:
extends: test
with:
afterBuild: pnpm test:unit
test-integration:
extends: test
with:
afterBuild: pnpm test:integrationjobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo build
test-unit:
uses: ./.github/workflows/test_reusable.yml
needs: build
with:
testTimingsArtifact: test-timings
afterBuild: pnpm test:unit
secrets: inherit
test-integration:
uses: ./.github/workflows/test_reusable.yml
needs: build
with:
testTimingsArtifact: test-timings
afterBuild: pnpm test:integration
secrets: inheritname: Build and test
on: [push]
call-templates:
test:
uses: ./.github/workflows/test_reusable.yml
needs: build
with:
testTimingsArtifact: test-timings
secrets: inherit
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo build
test-unit:
extends: test
with:
afterBuild: pnpm test:unit
test-integration:
extends: test
with:
afterBuild: pnpm test:integrationjobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo build
test-unit:
uses: ./.github/workflows/test_reusable.yml
needs: build
with:
testTimingsArtifact: test-timings
afterBuild: pnpm test:unit
secrets: inherit
test-integration:
uses: ./.github/workflows/test_reusable.yml
needs: build
with:
testTimingsArtifact: test-timings
afterBuild: pnpm test:integration
secrets: inheritMerge semantics
| Key | Behaviour |
|---|---|
with | Shallow per-key — { ...template, ...inline }. Each with.<key> the job sets replaces the template's; unspecified keys inherit. |
needs | Order-preserving union — template deps plus inline deps, de-duplicated. |
secrets | If either side is the string inherit, inline wins (replace). If both are maps, shallow per-key merge like with. |
if | AND-combined — template && inline, consistent with job-defaults. |
uses | Replace-on-presence — inline value wins entirely; the template only applies when the job sets no inline uses. |
A v1 template may declare only the call quad: uses, with, needs,
secrets, and if. Runner keys (runs-on, env, container, …), steps,
and the broader job knobs (permissions, concurrency, strategy) are
rejected — keep strategy on the job so for-each
stays its sole owner, and set permissions/concurrency per job or via
job-defaults.
Composing templates
extends: accepts a single name or a list. Templates are merged left-to-right
(later wins), and an inline value on the job always wins over all of them:
needs unions across both templates and the job; with.shared resolves to the
inline value while kept and added survive; the two secrets maps merge; and
both if gates combine:
call-templates:
base:
uses: ./.github/workflows/reuse.yml
needs: build
with:
shared: base-shared
kept: base-kept
secrets:
TOKEN: ${{ secrets.BASE }}
if: ${{ github.event_name == 'push' }}
extra:
needs: lint
with:
shared: extra-shared
secrets:
EXTRA: ${{ secrets.EXTRA }}
jobs:
composed:
extends: [base, extra]
needs: deploy
with:
shared: inline-shared
added: inline-added
if: ${{ success() }}jobs:
composed:
uses: ./.github/workflows/reuse.yml
needs:
- build
- lint
- deploy
with:
shared: inline-shared
kept: base-kept
added: inline-added
secrets:
TOKEN: ${{ secrets.BASE }}
EXTRA: ${{ secrets.EXTRA }}
if: github.event_name == 'push' && success()call-templates:
base:
uses: ./.github/workflows/reuse.yml
needs: build
with:
shared: base-shared
kept: base-kept
secrets:
TOKEN: ${{ secrets.BASE }}
if: ${{ github.event_name == 'push' }}
extra:
needs: lint
with:
shared: extra-shared
secrets:
EXTRA: ${{ secrets.EXTRA }}
jobs:
composed:
extends: [base, extra]
needs: deploy
with:
shared: inline-shared
added: inline-added
if: ${{ success() }}jobs:
composed:
uses: ./.github/workflows/reuse.yml
needs:
- build
- lint
- deploy
with:
shared: inline-shared
kept: base-kept
added: inline-added
secrets:
TOKEN: ${{ secrets.BASE }}
EXTRA: ${{ secrets.EXTRA }}
if: github.event_name == 'push' && success()Precedence with job-defaults
call-templates resolves before job-defaults, so a fully materialized call
job is what job-defaults sees. A template value (or inline job value) already
present blocks the default from overwriting it — precedence is
inline > extends > job-defaults. The if key AND-combines all three; for
keys a template can't carry (permissions, concurrency), job-defaults fills
them on the materialized call job. Keep strategy inline on the call job.
Call-job only
extends: is for reusable-workflow call jobs. A job that sets inline steps:
cannot extends:, and if no template in the chain provides uses: (and the job
sets none) Actio errors — the result would not be a call job. Templates carry
the call plumbing; they never carry runner configuration.
Compile-time interpolation
{{ params.* }} inside a call-templates block resolves before the template is
merged, so templates can be parameterized like any other Actio source.