Skip to main content
Back to Blog
Guide
2026-08-08

CI Workflow Reusable Inputs Secrets: Safe GitHub Actions Contracts

Master CI workflow reusable inputs secrets with typed contracts, least-privilege forwarding, nested workflow tests, and safer GitHub Actions debugging.

CI Workflow Reusable Inputs Secrets: Safe GitHub Actions Contracts

CI workflow reusable inputs secrets should be designed as an API boundary. Inputs are typed, non-secret configuration declared under on.workflow_call.inputs. Secrets are sensitive capabilities declared under on.workflow_call.secrets and passed by name from the calling job, or inherited when the trust boundary genuinely permits broad forwarding. The called workflow must validate configuration, request minimal token permissions, avoid logging sensitive values, and explicitly forward any secret needed by another nested workflow.

The safest default is a narrow contract: name every input, give optional values explicit defaults, name every secret, and map caller secret names to callee secret names. Use secrets: inherit only when the called workflow is trusted to receive the caller's whole available secret set. A reusable workflow does not automatically receive ordinary secrets, nested workflows receive only what their direct caller passes, and environment secrets can override an identically named passed secret inside a called job that declares an environment.

This guide builds a reusable GitHub Actions workflow for API smoke tests, its caller, validation scripts, negative contract probes, and a diagnosis path for misleading secret failures. Pair the design with CI cancellation of stale E2E runs when old commits waste capacity, and use CI test selection by Git diff when the caller needs to calculate a focused test scope.

Treat workflow_call as a versioned interface

A reusable workflow is not pasted YAML. It is an executable interface used by other repositories, branches, teams, and automation. Changing an input name, type, default, permission, output, or secret requirement can break callers. Review those changes with the same care as an API schema.

Contract elementDeclared by calleeSupplied by callerReview question
string inputname, type, required or defaultvalue under job-level withis validation narrower than "any string"?
boolean inputtype and defaultYAML boolean or expressiondoes code preserve boolean meaning?
number inputtype and defaultnumeric value or expressionare range and integer rules checked?
named secretname and required flagmapping under job-level secretsis this capability truly required?
permissionsworkflow or called jobcalling job may maintain or reducecan the token do more than the steps need?
outputworkflow output mapped from job outputconsumed through needsis the value non-sensitive and stable?

GitHub's current reuse documentation is at https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows. Workflow syntax is at https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax. Those references define the supported types and calling-job keywords. Check them when changing the interface rather than asking an agent to infer a key from ordinary job syntax.

The caller invokes a reusable workflow at the job level with uses. It is not a step. That calling job supports a limited set of keywords, including with, secrets, permissions, strategy, needs, if, and concurrency. A runs-on or steps block belongs inside the called workflow, not beside the calling job's uses.

Declare a narrow smoke-test contract

The example contract accepts a URL, an environment label, and a boolean that controls whether destructive smoke actions are skipped. It requires one API token. Save it as .github/workflows/reusable-api-smoke.yml.

name: Reusable API smoke

on:
  workflow_call:
    inputs:
      target_url:
        description: HTTPS origin of the test environment
        required: true
        type: string
      environment_name:
        description: Non-secret environment label for reports
        required: false
        type: string
        default: staging
      dry_run:
        description: Skip mutation checks when true
        required: false
        type: boolean
        default: true
    secrets:
      api_token:
        description: Token scoped to smoke-test endpoints
        required: true

permissions:
  contents: read

jobs:
  smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Validate reusable inputs
        env:
          TARGET_URL: ${{ inputs.target_url }}
          ENVIRONMENT_NAME: ${{ inputs.environment_name }}
        run: node scripts/validate-smoke-inputs.mjs

      - name: Run read-only smoke probe
        env:
          TARGET_URL: ${{ inputs.target_url }}
          API_TOKEN: ${{ secrets.api_token }}
        run: node scripts/smoke-read.mjs

      - name: Explain dry-run behavior
        if: ${{ inputs.dry_run }}
        run: echo "Mutation checks are disabled"

The token is exposed only to the step that needs it. The validation step receives non-secret inputs, and the informational step receives neither URL nor token. This reduces accidental exposure through diagnostic commands and third-party actions.

The workflow's permissions block constrains GITHUB_TOKEN. It does not change the scope of api_token, which is an independently managed secret. Give that credential a test-only identity, the smallest endpoint scope, and a short lifetime where the provider supports it.

Validate semantic constraints inside the called workflow

Type declarations stop a caller from passing a string where a boolean is required, but a string input can still be semantically dangerous. A URL might target production, use cleartext HTTP, embed credentials, or point to an unexpected host. Validate before passing it to test code.

Save the following as scripts/validate-smoke-inputs.mjs.

const targetValue = process.env.TARGET_URL;
const environmentName = process.env.ENVIRONMENT_NAME;

if (targetValue === undefined || environmentName === undefined) {
  throw new Error("TARGET_URL and ENVIRONMENT_NAME are required");
}

const target = new URL(targetValue);
const allowedHosts = new Set([
  "staging.example.test",
  "preview.example.test",
]);

if (target.protocol !== "https:") {
  throw new Error("Smoke target must use HTTPS");
}
if (target.username !== "" || target.password !== "") {
  throw new Error("Smoke target must not contain credentials");
}
if (!allowedHosts.has(target.hostname)) {
  throw new Error("Smoke target host is not allowlisted");
}
if (!/^[a-z][a-z0-9-]{1,30}$/.test(environmentName)) {
  throw new Error("Environment label has an invalid format");
}

console.log("Validated smoke target for", environmentName);

The hosts use the reserved .test top-level domain, so replace them with authorized non-production hosts in a real repository. Do not turn an input directly into a shell command, file path, ref, or deployment target. Expressions are evaluated by GitHub Actions, then values enter the shell environment. Validate at the application-language boundary and avoid eval.

The smoke program can use Node's built-in fetch. It prints status and a boolean contract, never the token or complete response body. Save it as scripts/smoke-read.mjs.

const targetValue = process.env.TARGET_URL;
const token = process.env.API_TOKEN;

if (targetValue === undefined || token === undefined) {
  throw new Error("TARGET_URL and API_TOKEN are required");
}

const endpoint = new URL("/health", targetValue);
const response = await fetch(endpoint, {
  headers: { authorization: "Bearer " + token },
  signal: AbortSignal.timeout(15_000),
});

if (!response.ok) {
  throw new Error("Health probe failed with status " + response.status);
}

const body = await response.json();
if (
  typeof body !== "object" ||
  body === null ||
  !("ok" in body) ||
  body.ok !== true
) {
  throw new Error("Health response did not satisfy the contract");
}

console.log("Health probe passed with status", response.status);

Masking is a last line of defense, not permission to print a secret. GitHub masks registered secret values in logs, but derived, transformed, encoded, split, or newly fetched sensitive values may require separate handling. Keep logs structural: status, rule ID, environment label, and correlation ID.

Call the workflow with explicit mappings

The same-repository caller uses a relative workflow path. Save this as .github/workflows/pr-smoke.yml.

name: Pull request smoke

on:
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  call-smoke:
    if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
    uses: ./.github/workflows/reusable-api-smoke.yml
    with:
      target_url: https://preview.example.test
      environment_name: pr-smoke
      dry_run: true
    secrets:
      api_token: ${{ secrets.PREVIEW_SMOKE_TOKEN }}

The condition avoids attempting the secret-backed job for pull requests from forks. GitHub does not pass ordinary secrets to workflows triggered from forks, with documented exceptions and event-specific behavior. Never switch to a more privileged trigger merely to make a fork test receive secrets. Split untrusted code checks from secret-backed post-merge or trusted-branch checks.

Explicit mapping documents capability flow. The caller calls its stored secret PREVIEW_SMOKE_TOKEN, while the callee sees only the interface name api_token. That decouples repository naming from the reusable contract and makes a review diff show exactly which credential crosses the boundary.

For a workflow in another repository, pin the uses reference according to supply-chain policy. A full commit SHA offers immutability; a tag or branch is easier to update but can move. A local relative call uses the workflow from the same commit as the caller and does not accept an @ref suffix.

Choose named secrets before inherit

secrets: inherit is concise, but its security meaning is broad: the directly called workflow can access the caller's available secrets within GitHub's documented scope rules. That may be reasonable for an organization-owned platform workflow with a reviewed trust boundary, but it should be a conscious decision.

Forwarding styleVisibility in callerCallee capabilityRecommended use
named mappingeach secret appears in YAMLonly mapped namesdefault for test workflows
secrets: inheritindividual names are hiddenbroad directly available settightly controlled same organization or enterprise
no secrets keyno ordinary secret forwardingautomatic token behavior onlypublic checks and untrusted code
OIDC permissionno long-lived cloud keyshort-lived federated identity if configuredcloud access with provider trust policy

What people get wrong is treating inherit as recursive. Secrets pass only to the directly called workflow. If workflow A calls B and B calls C, C receives a secret only if B passes or inherits it onward. This one-hop rule is valuable because every boundary gets a review opportunity.

Another mistake is declaring every possible secret optional so one reusable workflow can do everything. The result contains conditional branches that are hard to test and a caller cannot tell which capabilities a mode truly needs. Prefer smaller workflows with required secrets that match one purpose, or separate secret-free validation from authenticated operations.

Forward one capability through a nested workflow

Suppose the smoke workflow delegates contract verification to another reusable workflow. B must map the secret to C explicitly. The nested workflow below is saved as .github/workflows/reusable-contract-check.yml.

name: Reusable contract check

on:
  workflow_call:
    inputs:
      target_url:
        required: true
        type: string
    secrets:
      contract_token:
        required: true

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - name: Verify API contract
        env:
          TARGET_URL: ${{ inputs.target_url }}
          API_TOKEN: ${{ secrets.contract_token }}
        run: node scripts/smoke-read.mjs

Workflow B can call it with this job:

jobs:
  delegate-contract-check:
    uses: ./.github/workflows/reusable-contract-check.yml
    with:
      target_url: ${{ inputs.target_url }}
    secrets:
      contract_token: ${{ secrets.api_token }}

The names intentionally differ at each boundary. That makes capability translation visible: A's PREVIEW_SMOKE_TOKEN becomes B's api_token, then C's contract_token. If C reports that its secret is empty, inspect the B-to-C mapping before changing repository settings.

Permissions also cannot be elevated through a nested chain. A caller can maintain or reduce permissions available downstream. Design the top-level caller and each reusable workflow with explicit minimal permissions so an implicit default does not vary with organization settings.

Keep environment secrets from shadowing passed secrets

Environment secrets behave differently from workflow_call secrets. The reusable workflow trigger does not accept an environment key for forwarding environment secrets. If a job inside the called workflow declares environment: staging, a secret with the same name in that environment is used instead of the caller-passed secret.

Consider this valid but surprising called-job fragment:

jobs:
  smoke:
    environment: staging
    runs-on: ubuntu-latest
    steps:
      - name: Probe with the selected token
        env:
          API_TOKEN: ${{ secrets.api_token }}
        run: node scripts/smoke-read.mjs

If the staging environment defines api_token, it can shadow the value passed by the caller. The symptom is maddening: the caller mapping looks correct, the job receives a non-empty token, but authentication identifies the wrong account.

Use distinct names for environment-managed deployment credentials and callable workflow secrets, or let the caller choose a non-secret environment label while keeping credential selection at one well-documented layer. When environments provide approval gates, reviewers, or branch restrictions, retain those controls but make the credential source explicit.

Diagnose missing, wrong, and overpowered credentials

A failed authentication does not automatically mean the secret was never forwarded. Diagnose from the contract outward without printing values.

SymptomLikely causeSafe diagnostic
workflow rejected before jobs startmissing required input or secret, wrong type, unknown namecompare caller keys with workflow_call declarations
step sees undefined environment variablemapping omitted or expression resolves unavailable secretlog presence as a boolean, never the value
authentication uses wrong identityenvironment secret shadows caller mappinginspect called job's environment and secret names
nested workflow lacks secretintermediate workflow did not forward ittrace each direct call boundary
fork PR job is skipped or secret absentuntrusted event does not receive ordinary secretsrun secret-free checks for forks
downstream API allows too muchexternal token scope is broadinspect provider-side identity and permissions

A presence check can fail safely without revealing length or prefix. Use it only inside the step already authorized to receive the secret.

set -euo pipefail

if [[ -z "${API_TOKEN:-}" ]]; then
  printf '%s\n' 'API_TOKEN is unavailable' >&2
  exit 1
fi

printf '%s\n' 'API_TOKEN is available to this step'

The braces are essential in shell variable expressions. When composing values, write ${CI_PIPELINE_ID}_${CI_NODE_INDEX} rather than ambiguous unbraced variables. In GitHub Actions, prefer the native expression contexts for workflow data and use shell variables only after values have been placed deliberately into env.

Never debug by enabling shell tracing around secret-bearing commands. set -x can echo expanded arguments. Do not dump all environment variables. A boolean presence signal, caller run ID, selected non-secret environment, and server-side authentication audit are enough to distinguish most causes.

Replace long-lived cloud secrets with OIDC where possible

If a reusable workflow needs a cloud identity, OpenID Connect can exchange GitHub's job identity for a short-lived provider credential. The job requests id-token: write, the provider validates claims such as repository and ref, and an official provider action performs the exchange. This removes a long-lived cloud key from repository secrets, but it does not remove the need for least privilege.

Do not copy a generic OIDC block without configuring the provider trust policy. The secure contract includes both sides: GitHub permissions and cloud-side claim restrictions. Limit accepted organization, repository, workflow, branch or environment as appropriate, then grant the federated role only the API operations needed by the test.

Keep ordinary application test credentials separate. OIDC is suitable when the target provider supports federation. It is not a magic conversion for every service token, and an overpowered federated role can be as dangerous as an overpowered stored key during the job lifetime.

Test the interface with positive and negative callers

Reusable workflow changes deserve contract tests. Maintain a manual or scheduled smoke caller in a sandbox repository or protected branch with a disabled test credential. Exercise the default path, each optional mode, nested forwarding, and output mapping. Then review negative cases at pull-request time through static inspection and small validation scripts.

Contract caseExpected resultEvidence
all required values suppliedcalled job starts and probe passesrun links caller and callee
required secret omittedcall is rejected before sensitive stepfailure names contract boundary
boolean passed as stringtype validation rejects callno coercion-dependent behavior
disallowed target hostvalidation script exits nonzerono network request is attempted
fork pull requestsecret-backed job does not runsecret-free jobs still report
nested mapping removeddeepest workflow cannot receive tokendiagnosis points to intermediate call

Do not run destructive negative tests against production. Use disabled credentials, reserved hosts, mock services, or a dedicated sandbox. Numbers such as retry counts, matrix size, and timeouts should come from observed system behavior, not be invented by an agent to make the YAML look complete.

For releases of a cross-repository reusable workflow, publish a changelog that calls out input, output, secret, and permission changes. Test callers against the candidate commit SHA, then move an approved release tag if that is your distribution model. Consumers that pin a SHA need an intentional update mechanism.

Maintain a small caller inventory for high-impact workflows. GitHub can show workflow usage, but the platform team should still know which repositories depend on each supported contract and which reference they pin. Before removing an input, search those callers and provide a migration window. Before making an optional secret required, prove every supported execution mode needs it. Contract ownership also includes failure text: validation errors should name the rejected input or missing capability without echoing its value. Clear boundary errors shorten incidents and discourage unsafe debugging commands.

Frequently Asked Questions

Are secrets automatically available inside a reusable GitHub Actions workflow?

Ordinary secrets are not automatically passed to a reusable workflow. The calling job must map named secrets under secrets or use secrets: inherit where GitHub permits it. The automatically available GITHUB_TOKEN follows its documented context and permission rules. For named mappings, the key must match the called workflow's declared secret name. In nested workflows, each direct caller must forward the capability again. Test presence without printing the value, and inspect event restrictions for forks or Dependabot before assuming a configuration defect.

When is secrets inherit acceptable for a reusable workflow?

Use inheritance only when the called workflow is within a clearly trusted organization or enterprise boundary and is reviewed to receive the caller's broad available secret set. Named mapping is easier to audit because every capability appears in the caller diff. Inheritance can be appropriate for a centrally governed platform workflow, but it raises the cost of reviewing third-party actions, debug steps, and future changes inside that workflow. It is direct-call only, not automatic recursive forwarding through every nested workflow.

Can an environment secret override a secret passed by the caller?

Yes. If a job in the called workflow declares an environment, an identically named secret from that environment is used instead of the caller-passed secret. The workflow_call trigger itself does not support forwarding an environment. This can produce successful secret presence checks but authenticate as the wrong identity. Avoid name collisions, document where credential selection occurs, and inspect the called job's environment key during diagnosis. Preserve environment approvals where needed, but keep the source of each credential unambiguous.

How should QA test changes to reusable workflow inputs and secrets?

Treat the workflow like a versioned API. Exercise a positive sandbox caller, omitted required values, wrong input types, unsafe target strings, fork behavior, nested secret forwarding, and least-privilege token access. Keep real credentials out of fixtures and logs. Validate non-secret strings before network or shell use, and prove secret-backed steps receive only the variables they need. For cross-repository consumers, test the candidate commit SHA before updating a release tag or pin, then record any contract or permission change in release notes.