Actio
Macros

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 jobsuses:, 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:

.actio.yml
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:integration
generated .yml
jobs:
  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: inherit
.actio.yml
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:integration
generated .yml
jobs:
  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: inherit

Merge semantics

KeyBehaviour
withShallow per-key{ ...template, ...inline }. Each with.<key> the job sets replaces the template's; unspecified keys inherit.
needsOrder-preserving union — template deps plus inline deps, de-duplicated.
secretsIf either side is the string inherit, inline wins (replace). If both are maps, shallow per-key merge like with.
ifAND-combinedtemplate && inline, consistent with job-defaults.
usesReplace-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:

.actio.yml
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() }}
generated .yml
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()
.actio.yml
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() }}
generated .yml
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.

On this page