GitHub Actions Reusable Workflows: Contracts, Permissions, and Safe Composition
Build reusable GitHub Actions workflows with typed inputs, explicit outputs, least-privilege tokens, pinned references, and testable caller contracts.
Reusable GitHub Actions workflows turn a repeated job sequence into a callable pipeline component. They are useful when several repositories need the same release checks, deployment guardrails, or build procedure, but reuse only helps when the component has a deliberate interface. Treat the called workflow as an API: define typed inputs, name required secrets, expose stable outputs, constrain the token, and test the caller and callee together.
A reusable workflow is not a composite action. An action runs inside a job’s steps; a reusable workflow is invoked at the job level and can contain multiple jobs, dependencies, permissions, and runner selections. It is also not a separate workflow run: the called jobs participate in the caller’s run and are visible in that run’s graph. Those boundaries determine where inputs belong, how outputs are consumed, and which security controls must be reviewed.
Define a small, typed workflow contract
Store the called workflow directly under .github/workflows/; nested directories beneath that directory are not supported for workflow files. Add workflow_call to its triggers, then declare only the inputs and secrets that callers are allowed to provide. Inputs must declare one of the supported types (string, boolean, or number), and callers must pass values compatible with that type.
For example, a build workflow can accept a source directory and an opt-in test switch without accepting arbitrary shell commands or an unrestricted deployment target:
name: Reusable application validation
on:
workflow_call:
inputs:
source-directory:
description: Directory containing the application source
required: false
type: string
default: .
run-integration-tests:
description: Whether to run the integration-test job
required: false
type: boolean
default: false
outputs:
package-digest:
description: Digest reported by the package job
value: ${{ jobs.package.outputs.digest }}
jobs:
package:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
digest: ${{ steps.hash.outputs.digest }}
steps:
- uses: actions/checkout@v7
with:
sparse-checkout: ${{ inputs.source-directory }}
- name: Build and test
working-directory: ${{ inputs.source-directory }}
run: |
npm ci
npm test
npm run build
tar -cf package.tar -C dist .
- id: hash
name: Record package digest
working-directory: ${{ inputs.source-directory }}
run: echo "digest=$(sha256sum package.tar | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"
- name: Upload package for downstream jobs
uses: actions/upload-artifact@v7
with:
name: application-package
path: ${{ inputs.source-directory }}/package.tar
if-no-files-found: error
retention-days: 7
The sample is a contract sketch, not a drop-in universal build. Its build commands and output path must match the repository. The important design choice is that callers provide data, while the reusable workflow owns the commands and policy. Avoid accepting a caller-controlled run: string: it turns the shared workflow into an indirect arbitrary-code execution interface.
The action references in these examples use major-version tags for readability. For stricter supply-chain controls, pin each action to a reviewed full commit SHA and update that pin through a reviewed dependency change. Pinning a reusable-workflow ref does not pin the actions called inside that workflow.
Use inputs.<name> to read declared input values. A boolean input should remain a boolean instead of being represented by strings such as "true" and "false"; this avoids truthiness bugs in conditional expressions. Give optional inputs safe defaults, document any compatibility-sensitive default, and fail early when combinations are invalid. Keep an input only if a real caller needs it. A broad configuration surface is harder to validate and makes it easier for repositories to bypass important checks.
Secrets form a separate contract. Declare each expected secret with on.workflow_call.secrets, set required: true only when the workflow cannot safely operate without it, and pass the secret from the caller by name. Do not make a secret an ordinary string input: the named secret mapping makes sensitive dependencies visible at the call site and supports review of where credentials enter the workflow.
Call the workflow as a job and pin shared code
Call a reusable workflow in a job’s uses field, not in a step. A same-repository call can use a relative path; a cross-repository call includes the owner, repository, workflow path, and ref:
name: Validate pull request
on:
pull_request:
permissions:
contents: read
jobs:
validate:
uses: acme/platform-workflows/.github/workflows/application-validation.yml@0123456789abcdef0123456789abcdef01234567
with:
source-directory: services/catalog
run-integration-tests: true
secrets:
package_read_token: ${{ secrets.PACKAGE_READ_TOKEN }}
Use a full commit SHA for a centrally maintained workflow when the caller needs an immutable reference. A branch can move, and tags can be changed by someone with permission to update them; a SHA identifies the exact commit. Review and update pinned revisions deliberately, ideally through an automated pull request that shows the shared-workflow changes. A same-repository relative call resolves the called file from the same commit as the caller, which keeps the pair aligned in that revision.
The cross-repository reference cannot be built from an expression. That restriction makes the workflow dependency visible in the YAML and prevents dynamically selecting a workflow file at runtime. Confirm that the called repository and workflow are accessible to the caller under the repository or organization settings. A syntactically valid uses reference still fails if access policy does not permit the call.
The caller controls when the job runs and how it fits into its dependency graph. Add needs when validation must wait for another job; use if only for conditions that are part of the caller’s intended policy. A reusable workflow’s internal jobs do not replace a caller’s branch protection rules: require the appropriate caller check in repository rules and verify the check’s stable name and behavior before changing required status checks.
Pass only the credentials the callee needs
Named secret passing makes the boundary explicit:
jobs:
publish:
uses: acme/platform-workflows/.github/workflows/publish-package.yml@0123456789abcdef0123456789abcdef01234567
permissions:
contents: read
packages: write
with:
package-name: catalog
secrets:
registry-token: ${{ secrets.REGISTRY_TOKEN }}
The permissions block is part of the caller’s authorization decision. Start from minimal read access, then grant a write permission only to the job that needs it. An action can access GITHUB_TOKEN through the github.token context even if the workflow does not explicitly pass it as a secret, so omitting the secrets mapping does not make an over-permissioned token harmless. Use job-level permission declarations to narrow access where possible and audit the actions and scripts that run with that token.
In a chain such as caller A → reusable workflow B → reusable workflow C, secrets are passed to the directly called workflow, not automatically forwarded through the entire chain. If B needs to call C with a credential, B must explicitly pass that credential onward. This behavior is a useful least-privilege boundary: each workflow can disclose only the secrets it has chosen to forward.
secrets: inherit is available for calls within the same organization or enterprise. It passes the caller’s available secrets as a set, which can be convenient during a migration but obscures the callee’s credential requirements. Prefer named secrets for a stable production interface. If inheritance is necessary, document why, inspect every action and script in the callee, and avoid forwarding the inherited set into another workflow without a specific need.
Environment secrets require special care. workflow_call does not let the caller pass an environment object as a workflow-call parameter. If a job in the called workflow declares an environment, that environment’s secret is used for the job; it is not a caller-provided environment secret with the same name. Put deployment approval and environment selection in the workflow that owns the deployment boundary, and confirm which environment protects the job before relying on required reviewers.
Permissions cannot be elevated as a nested workflow chain proceeds. A called workflow can retain or reduce the permission level it receives, but it cannot use nesting to grant a later workflow more access than the caller allowed. Review this together with repository settings that control workflow access and token defaults; do not assume a reusable file creates an independent security principal.
Expose outputs through every required layer
Reusable workflow outputs have a three-layer mapping: a step output becomes a job output, the workflow-call output refers to that job output, and the caller reads the result from the called job’s needs context. Declaring only a step output is not enough. Keep output names stable and describe whether the value is an identifier, status, digest, or path; consumers should not have to parse a human-readable log line.
on:
workflow_call:
outputs:
package-digest:
description: SHA-256 digest of the built package
value: ${{ jobs.package.outputs.digest }}
jobs:
package:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.hash.outputs.digest }}
steps:
- id: hash
run: echo "digest=$(sha256sum package.tar | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"
Then consume it only after declaring the dependency:
jobs:
package:
uses: acme/platform-workflows/.github/workflows/package.yml@0123456789abcdef0123456789abcdef01234567
attest:
needs: package
runs-on: ubuntu-latest
steps:
- env:
PACKAGE_DIGEST: ${{ needs.package.outputs.package-digest }}
run: printf 'Package digest: %s\n' "$PACKAGE_DIGEST"
For matrix calls, do not treat a single workflow output as a collection of every matrix result. GitHub documents that the output comes from the last successfully completing matrix child that sets a value; completion order is not a stable platform ordering. If every matrix result matters, persist each result under a distinct artifact or another explicit aggregation channel, then use a separate job to collect and validate the complete set.
Compose workflows without creating hidden complexity
Reusable workflows can call other reusable workflows, but their graph has a documented limit of ten levels including the top-level caller, and cycles are not allowed. A short chain with clear ownership can factor shared validation and release steps. A deep chain makes it difficult to understand which repository, permission boundary, or pinned revision controls a production deployment. Keep the call graph shallow enough that reviewers can trace credentials, inputs, and checks from the top-level trigger to the job that performs a sensitive operation.
Use a matrix when one interface should run the same reusable procedure against a bounded set of values, such as a supported runtime matrix. Make each matrix value explicit and check that the called workflow can safely operate for every combination. Avoid mixing unrelated deployment targets or trust levels in one matrix job when they require different approvals, environments, or credentials.
A workflow is a good reuse boundary when multiple callers share both the steps and the policy. If callers need substantially different shell sequences, runner images, or privilege levels, separate workflows or a lower-level action may be more maintainable. A composite action is often a better fit for reusable steps that run within a caller-owned job; a reusable workflow is appropriate when the unit includes a job or multi-job orchestration. Choose the smallest abstraction that preserves the security and operational contract.
Version, review, and test the interface
Treat changes to required inputs, secret names, output names, permissions, runner labels, and job/check names as API changes. Before publishing a new revision, test a minimal caller and at least one realistic caller. Include negative cases: omit a required input or secret, pass a value of the wrong type, deny a required permission, provide an unavailable workflow ref, and exercise the path where a job fails before producing an output. Verify the visible result in the caller run rather than relying only on static YAML validation.
Pin action dependencies inside the reusable workflow according to your supply-chain policy. Pinning the workflow itself does not pin third-party actions referenced inside it. Review those action references, inspect the exact revision being called, and check what code receives credentials. Keep release notes for reusable-workflow changes and update caller pins in reviewable batches so a breaking interface change is not silently introduced across production repositories.
For deployment workflows, preserve the caller’s explicit deployment intent. Separate validation from production promotion; use protected environments and required reviewers where appropriate; constrain GITHUB_TOKEN; and make the target environment a reviewed input only if policy allows callers to select it. Do not let an untrusted pull-request value choose a privileged environment, secret, or shell command. Keep untrusted event data out of inline shell source, and pass validated values through environment variables or purpose-built actions.
Production review checklist
- The reusable workflow is in
.github/workflows/and declaresworkflow_call. - Inputs have descriptions, supported types, safe defaults, and validation for incompatible values.
- Secrets are named and passed deliberately;
inheritis justified and reviewed. - The caller invokes the workflow at the job level and uses a reviewed, immutable ref for cross-repository reuse.
- Caller and callee token permissions are least-privilege, including the implicit availability of
GITHUB_TOKENto actions. - Environment secrets, approvals, and deployment targets are owned by the intended workflow boundary.
- Outputs map from step to job to workflow and are consumed with an explicit
needsdependency. - Matrix result aggregation does not depend on completion order or a single scalar output.
- Nested calls are accessible, acyclic, shallow, and do not expect permissions to increase.
- Interface, failure-path, access-policy, and pinned-revision changes are tested in a real caller run.
Reusable workflows make automation consistent only when their contracts stay explicit. Typed inputs, narrowly passed credentials, immutable references, predictable outputs, and caller-level tests turn shared YAML into a maintainable pipeline component rather than an opaque dependency.
Related:
- GitHub Actions OIDC: Short-Lived Cloud Credentials Without Repository Secrets
- GitHub Actions Artifacts: Reliable Handoffs, Matrix Jobs, and Retention
Sources: