Engineering inference. A reusable workflow is not merely a YAML deduplication mechanism. As soon as a job invokes another file with jobs.<job_id>.uses, it creates a trust boundary among the calling repository, the repository that hosts the called workflow, the secrets made available, GitHub environments, and the effective GITHUB_TOKEN permissions. The right acceptance question is therefore not “does the workflow run?” but “what authority does the called workflow actually receive, and what can it pass farther downstream?” Execution success alone is insufficient evidence because the delegated authority covers both data access and repository operations.
Documented behavior. GitHub distinguishes inputs declared under on.workflow_call.inputs, declared or inherited secrets, GITHUB_TOKEN permissions, environment secrets, and nested calls. A particularly important point is that secrets: inherit lets the called workflow reference inherited secrets even when they are absent from its on.workflow_call.secrets declaration. An apparently narrow declaration is therefore not necessarily an allowlist when the caller chooses inheritance.
Proposed experiment. The lab in this playbook uses only disposable repositories and synthetic values named TEST_SENTINEL_A and TEST_SENTINEL_B. Do not introduce any PAT, cloud token, registry credential, production secret, or customer repository. The outcomes below are expected outcomes to compare with observations from a future run; no GitHub Actions tests were executed to write this article.
Evidence class | Meaning in this playbook |
Documented behavior | A rule explicitly described in the cited GitHub documentation |
Engineering inference | A security conclusion or acceptance rule derived from documented behavior |
Proposed experiment | A reproducible test to run only in the disposable lab |
Actual observation | None in this article; reserve this class for data captured during an actual run |
Why reusable workflows require a separate trust-boundary review
Documented behavior. A reusable workflow is called at the job level with uses, and its github context remains associated with the calling workflow. GitHub also states that the called workflow automatically has access to github.token and secrets.GITHUB_TOKEN. The permissions passed by the caller can then only remain the same or be reduced by the called workflow.
Primary source: GitHub Docs, Reuse workflows, page date not stated, accessed September 22, 2026.
Engineering inference. This means a repository can contain a tiny, carefully reviewed caller file while delegating execution to many lines maintained elsewhere. If that remote workflow receives secrets or a token permitted to modify the calling repository, the maintainers of the central workflow become part of the caller’s trust perimeter. Review the call graph, not only the local YAML. A local approval cannot substitute for review of the referenced revision and the people permitted to maintain it.
This boundary is distinct from the cache boundary covered in Trace GitHub Actions Cache Rights Across Reusable Workflows. A cache, a token permission, and a passed secret answer three different questions; a control over one proves nothing about the other two.
Element | Acceptance question | Class |
Calling workflow | Who decides to invoke the reusable code, and on which event? | Engineering inference |
Called workflow | Who can modify the code executed on behalf of the caller? | Engineering inference |
secrets: | Which names are passed explicitly? | Documented behavior |
secrets: inherit | Which set of secrets becomes available to the callee? | Documented behavior |
permissions: | Which GitHub operations can the token perform? | Documented behavior |
environment: | Which protections and secrets are added to the job? | Documented behavior |
Nested workflow | Which authority is passed to the next hop? | Documented behavior |
Acceptance criterion (engineering inference). No reusable workflow should be approved until the reviewer can draw A → B → C, annotate each edge with the secrets passed, and annotate each job with its effective permissions.
Inventory caller, called workflow, repository, environment, and organization scopes
Documented behavior. Reusable workflow accessibility depends in part on repository visibility and access settings. GitHub documents that a workflow in the same repository is accessible; that a workflow in a public repository can be called when organization policy allows it; and that a private repository hosting a reusable workflow must be configured to permit the required access.
Primary source: GitHub Docs, Reusing workflow configurations, page date not stated, accessed September 22, 2026.
Engineering inference. The inventory must separate five scopes: the calling repository, the called repository, the organization or enterprise, any referenced GitHub environment, and the specific job. Writing “pipeline secret” is not enough. The record should be precise: “TEST_SENTINEL_A, configured as a repository secret in lab-caller, passed as approved_secret to this workflow revision.” Apply the same precision to every secret, token permission, environment, and workflow hop.
Field to record | Lab example | Why it matters | Class |
Caller | lab-caller | Origin of the context and token | Documented + inventory |
Callee | lab-reusable | Code to which authority is delegated | Documented + inventory |
Called revision | Reviewed SHA | Prevents auditing one version and executing another | Inference |
Visibility | public/private | Affects cross-repository accessibility | Documented |
Secret scope | repository / organization / environment | Determines its possible source | Documented |
Caller name | TEST_SENTINEL_A | Identity of the source | Inventory |
Callee name | approved_secret | Interface actually exposed | Inventory |
Permissions | contents: read | Token authority ceiling | Documented |
Environment | lab-approval | Adds job-specific protections and secrets | Documented |
Trigger | workflow_dispatch, pull_request, push | Determines the trust context | Documented |
Engineering inference. For cross-repository calls, also record the team that can modify the callee repository. GitHub authorization that allows the caller to access the workflow does not answer the organizational question, “who can change this code tomorrow?” The ownership record should identify current maintainers and the review controls applied to their changes.
Runner status remains a separate control. To inventory its version, image, and support lifecycle without confusing it with workflow_call delegation, use Know When Your Runner Expires: Automating the GitHub Actions Runner Lifecycle.
Acceptance checklist (engineering inference). Stop the review if the called revision cannot be identified, the origin of a secret is ambiguous, the reviewer does not know the called repository’s access settings, or an environment is referenced but absent from the inventory. Any one of these gaps makes the proposed result difficult to interpret reliably.
Model explicit secrets mapping versus secrets inherit
Documented behavior. jobs.<job_id>.secrets supplies a map of secrets to the reusable workflow. GitHub requires the explicitly passed names to match names defined by the called workflow. By contrast, with secrets: inherit, GitHub describes the transfer of all secrets available to the calling workflow and allows this use between repositories in the same organization or between organizations in the same enterprise.
Explicit mapping as an allowlist
Engineering inference. Explicit mapping is the easiest model to audit because the caller → callee edge itself shows the authorized channels. It does not, however, guarantee the secret’s meaning. A caller could technically put the value of TEST_SENTINEL_B into a parameter named approved_secret. Acceptance must therefore verify both the exposed name and the value’s source. A descriptive alias is useful evidence, but it is not a provenance guarantee.
name: Caller - explicit mapping
on:
workflow_dispatch:
permissions:
contents: read
jobs:
reusable-audit:
permissions:
contents: read
uses: ./.github/workflows/reusable-audit.yml
with:
audit_mode: explicit
secrets:
approved_secret: ${{ secrets.TEST_SENTINEL_A }}name: Reusable audit
on:
workflow_call:
inputs:
audit_mode:
required: true
type: string
secrets:
approved_secret:
required: true
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Verify approved secret exists without printing it
shell: bash
env:
APPROVED: ${{ secrets.approved_secret }}
run: test -n "$APPROVED"Proposed experiment. Configure TEST_SENTINEL_A and TEST_SENTINEL_B in the disposable repository. The call above maps only A. The expected outcome is that approved_secret is available to the callee while B is not introduced by this edge. Never log the value to verify this; test presence or absence only. Record the caller and callee revisions before running the check.
What secrets: inherit actually changes
Documented behavior. GitHub states that when a caller uses secrets: inherit, the reusable workflow can reference inherited secrets even if they are not explicitly defined under on.workflow_call.secrets. The callee declaration therefore stops being a complete description of the secrets it can read.
jobs:
reusable-audit:
permissions:
contents: read
uses: ./.github/workflows/reusable-audit.yml
secrets: inheritProposed experiment. Keep only the approved_secret declaration in the callee, then add an availability check for ${{ secrets.TEST_SENTINEL_B }}. With the inherit call, the expected result, when B is actually in the caller’s inheritable set, is that B can be referenced despite being absent from the on.workflow_call.secrets contract. That is the negative case to capture; do not print B.
Caller | Callee declaration | Value tested | Expected result | Class |
Explicit mapping A → approved_secret | approved_secret | approved_secret | Available | Proposed experiment |
Explicit mapping of A only | approved_secret | TEST_SENTINEL_B | Not introduced by the mapping | Proposed experiment |
secrets: inherit | approved_secret only | TEST_SENTINEL_A | May be inherited according to scope | Documented / experiment |
secrets: inherit | approved_secret only | TEST_SENTINEL_B | May be referenced without prior declaration | Documented / experiment |
Explicit mapping of an undeclared name | No matching name | Additional secret | Configuration error expected | Documented |
Engineering inference. For a sensitive central workflow, treat secrets: inherit as a trust-boundary change, not as a syntax convenience. Give the change the same review attention as a new secret-transmission channel.
Validate workflow_call inputs and secret declarations
Documented behavior. on.workflow_call.inputs defines the inputs a caller may provide. Every declared input must have a type; GitHub supports boolean, number, and string. A caller that provides an input absent from the contract produces an error. Likewise, in the explicit-secret model, passing a secret not defined under on.workflow_call.secrets produces an error.
Primary source: GitHub Docs, Workflow syntax for GitHub Actions, page date not stated, accessed September 22, 2026.
Engineering inference. Inputs must never be confused with secrets. They form the reusable workflow’s functional API and can influence its behavior: a path, logical environment, build mode, or requested operation. Being typed does not make an input trusted. If a pull-request-controlled value selects a command or path executed by a privileged job, the issue is the trust granted to that value. Validate permitted values and keep privileged behavior independent from untrusted strings wherever possible.
on:
workflow_call:
inputs:
audit_mode:
description: "Synthetic lab mode"
required: true
type: string
secrets:
approved_secret:
required: trueValidation case | Proposed mutation | Expected result | Evidence to retain | Class |
Normal input | audit_mode: explicit | Workflow accepted | YAML + future run ID | Experiment |
Missing required input | Omit audit_mode | Call rejected | Validation message | Experiment |
Undeclared input | Add unexpected_mode | Error expected | GitHub message | Documented / experiment |
Wrong type | Provide an incompatible value | Validation fails | GitHub message | Documented / experiment |
Declared and mapped secret | A → approved_secret | Available | Presence test | Experiment |
Undeclared explicit secret | Add another name | Error expected | GitHub message | Documented / experiment |
Undeclared secret with inherit | Reference B in the callee | Potentially available | Boolean test, no value logged | Documented / experiment |
Engineering inference. Review the workflow_call contract as an API: minimal inputs, values validated in the callee, and secrets named for their role. The auditor must explicitly note, however, that the declared-secret filtering property does not apply in the same way to secrets: inherit.
Test GITHUB_TOKEN permissions at caller and called-workflow boundaries
Documented behavior. GitHub lets you set jobs.<job_id>.permissions to reduce permissions for the GITHUB_TOKEN. Once any permission is explicitly configured, unspecified permissions are set to none. For a job that calls a reusable workflow, the permissions passed by the caller cannot be elevated by the callee; they can only be maintained or reduced.
Token permissions as a separate authorization layer
Engineering inference. Secrets and the GITHUB_TOKEN are two distinct authority channels. A workflow that receives no application secret can still have a GITHUB_TOKEN. Conversely, a workflow can receive a synthetic secret while being unable to write to the repository because it has contents: read. Acceptance testing must cover both dimensions independently. A pass on one authority channel does not establish least privilege on the other.
jobs:
call-read-only:
permissions:
contents: read
uses: ./.github/workflows/token-boundary.ymlThe reusable workflow can then perform a read operation and attempt a controlled write against the disposable repository:
on:
workflow_call:
jobs:
token-test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Confirm repository metadata is readable
env:
GH_TOKEN: ${{ github.token }}
run: gh api "repos/${GITHUB_REPOSITORY}" >/dev/null
- name: Attempt synthetic contents write
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set +e
response="$(
gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/contents/.permission-sentinel" \
-f message='synthetic permission test' \
-f content='c3ludGhldGljCg==' \
2>&1
)"
status=$?
test "$status" -ne 0Proposed experiment. With contents: read, reading repository metadata should work within the available rights, while the content write must not succeed. Do not make the test depend on a single HTTP status code: acceptance concerns the absence of a write and the failure of the unauthorized operation.
Caller | Callee | Attempt | Expected | Class |
contents: read | contents: read | Read repository | Allowed within the scope | Experiment |
contents: read | contents: read | Write file | Failure, no file created | Experiment |
contents: read | Requests contents: write | Start with greater rights | Elevation impossible | Documented |
Permissions omitted | Permissions omitted | Use token | Depends on applicable default permissions | Documented |
Broader caller | Callee reduced to read | Write | Failure expected in this job | Documented / experiment |
Engineering inference. Treat omitted permissions as an audit finding, not as “no permissions.” GitHub documents that when the calling job lacks a permissions block, the callee receives the applicable default GITHUB_TOKEN permissions. The reviewer must identify which default applies to the tested repository and organization.
Do not confuse this layer with cloud identity federation. OIDC and its trust relationships are covered separately in GitHub OIDC Subject Migration: Audit AWS Trust Policies.
Test environment secrets and deployment protection assumptions
Documented behavior. A GitHub environment can have its own secrets and protection rules. A job that references the environment must satisfy the applicable protections before it can access environment secrets. GitHub also states that those secrets are available only to jobs using that environment.
Primary source: GitHub Docs, Managing environments for deployment, page date not stated, accessed September 22, 2026.
Environment secrets are not ordinary repository secrets
Documented behavior. The reusable workflow documentation adds an essential nuance: on.workflow_call has no environment keyword through which the caller can pass an environment as it passes a secret. If a job inside the reusable workflow specifies an environment, a secret defined in that environment can be used instead of a caller-passed secret with the same name.
Engineering inference. A reviewer who reads only the caller can therefore underestimate the callee’s authority. The caller may appear to pass only a limited synthetic value, while an internal job in the reusable workflow attaches to an environment that holds a different value. The callee’s environment: belongs in the threat model. Review the callee job definition and the environment configuration together.
Proposed lab:
on:
workflow_call:
secrets:
approved_secret:
required: true
jobs:
environment-check:
environment: lab-approval
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Confirm a value exists without revealing it
env:
VALUE: ${{ secrets.approved_secret }}
run: test -n "$VALUE"Proposed experiment. In lab-caller, store synthetic value A as a repository secret and map it to approved_secret. In the disposable lab-approval environment, create a same-named synthetic secret with value B. Based on GitHub’s warning, the expected outcome is that the job using the environment resolves the environment secret for that name. Keep the comparison Boolean; do not publish either value in logs.
Test | Configuration | Expected | Risk detected by the test | Class |
No environment | Caller maps A | A supplies the callee name | Baseline | Experiment |
Environment without same-named secret | Caller maps A | A remains the explicitly received secret | Control | Experiment |
Environment with same-named B | Caller maps A | Applicable environment secret | Unexpected shadowing | Documented / experiment |
Protection not satisfied | Job references lab-approval | Job must not access the secret before applicable rules pass | Protection bypass | Documented / experiment |
Job without environment | Secret exists only in environment | Do not assume availability | Scope confusion | Inference |
Engineering inference. The general secrets: inherit reference also says that inheritance covers secrets available to the caller, including environment secrets, while the reusable workflow documentation warns that an environment is not passed through workflow_call. Do not turn this documentation nuance into a universal assumption: validate the exact job and record its exact environment.
Validate nested reusable workflows and transitive access
Documented behavior. In an A → B → C chain, secrets do not automatically jump from A to C. GitHub states that they are passed only to the directly called workflow: B must explicitly pass a secret again to C or use another appropriate inheritance mechanism. GitHub also requires GITHUB_TOKEN permissions to remain the same or become more restrictive through the chain.
Nested workflow trust expansion
Engineering inference. The security of A → B therefore does not imply the security of B → C. B is both a callee receiving authority and a caller able to pass some of it onward. This dual role is why every edge in the graph must be audited. Show both secret flow and token authority at every hop.
Synthetic case:
# Workflow A
jobs:
call-b:
permissions:
contents: read
uses: ./.github/workflows/workflow-b.yml
secrets: inherit# Workflow B
on:
workflow_call:
jobs:
call-c:
permissions:
contents: read
uses: ./.github/workflows/workflow-c.yml
secrets:
approved_secret: ${{ secrets.TEST_SENTINEL_A }}# Workflow C
on:
workflow_call:
secrets:
approved_secret:
required: trueProposed experiment. Put A and B in Workflow A’s initial scope. Workflow B, called with inherit, should be able to access inheritable secrets. B then passes only A under the alias approved_secret. Workflow C should pass its check for approved_secret but should not receive B through this second edge. This validates the B → C hop; it is not general proof about every possible path.
Hop | Secret mode | A expected | B expected | Token | Class |
A → B | inherit | Available if in scope | Available if in scope | ≤ A permissions | Documented / experiment |
B → C | map A only | Available under alias | Not passed by this edge | ≤ B permissions | Documented / experiment |
B → C | inherit | Inheritable | Inheritable | ≤ B permissions | Documented |
C → D with no secret | No mapping | Not automatically retransmitted | Not automatically retransmitted | Non-increasing permissions | Documented |
Engineering inference. The review should fail if an intermediate workflow contains secrets: inherit “by default” without justification. Every inherit is a full transmission decision that must be documented.
Test cross-repository reusable workflow calls
Documented behavior. GitHub supports the syntax {owner}/{repo}/.github/workflows/{filename}@{ref} for reusable workflows in other public or private repositories, subject to applicable access rules. The called workflow’s github context remains the caller’s context, and the called workflow receives the caller-context GITHUB_TOKEN, capped by its permissions.
Engineering inference. This is where an operational abstraction most clearly becomes a delegation of trust. A lab-app repository can execute YAML maintained in lab-reusable while granting that code authority from the lab-app context. The right to modify lab-reusable is therefore security-relevant to the caller. Pinning a revision limits code drift, but it does not answer who approved that revision.
jobs:
central-check:
permissions:
contents: read
uses: disposable-org/lab-reusable/.github/workflows/audit.yml@<reviewed-sha>
secrets:
approved_secret: ${{ secrets.TEST_SENTINEL_A }}Engineering inference. In the lab, replace <reviewed-sha> with a specific commit you inspected. GitHub accepts different reference forms for cross-repository workflows; choosing a reviewed SHA is an acceptance rule here to prevent a moving branch from changing the code between audit and execution.
Cross-repository case | Secret | Caller permission | Expected | Decision |
Callee accessible, SHA reviewed | A explicitly | contents: read | Valid call; A only through mapping | Acceptable for test |
Callee accessible | inherit | contents: read | Broader inheritable set | Additional review |
Callee requests write | A | contents: read | No token elevation | Negative test |
Callee inaccessible | None | read | Call fails | Access-control evidence |
Moving reference | A | read | Can change independently of caller | Restrict |
Callee maintainers not identified | A | read | Trust ownership unknown | Hold |
Proposed experiment. Use only two disposable repositories. First make an explicitly mapped call, then an inherit call in a scenario where that syntax is supported. Compare the availability of A and B without displaying either value. Record the effective callee SHA as well.
Engineering inference. Success in these two repositories does not prove that a production organization has the same access rules, Actions policies, or secret scopes.
Prove negative cases with sentinel secrets
Engineering inference. Positive tests answer only, “does authorized access work?” A secret-inheritance acceptance review must also demonstrate the forbidden cases: B does not reach a callee explicitly limited to A; C does not receive a secret that stops at B; a read-only job cannot write; and a job without an environment does not gain that environment’s secret by assumption. Each negative case should fail loudly when the forbidden capability appears.
Negative tests for unauthorized secret access
Proposed experiment. Give TEST_SENTINEL_A and TEST_SENTINEL_B unique synthetic values. Test availability by turning the result into success/failure or a non-sensitive Boolean. Never write echo "$SECRET" and never treat the appearance of *** as evidence.
Safe example for checking that an unintended secret is absent:
- name: Negative secret check
shell: bash
env:
UNAUTHORIZED: ${{ secrets.TEST_SENTINEL_B }}
run: |
if [ -n "$UNAUTHORIZED" ]; then
echo "Unexpected secret availability"
exit 1
fi
echo "Unauthorized sentinel not available in this case"Documented behavior. GitHub warns that automatic masking is not a universal guarantee and explains that sensitive-data masking is performed by the runner. Security therefore cannot be inferred from how logs look.
Negative test | Setup | Expected success | Security failure |
N1 | Explicit mapping A | A present | A absent |
N2 | Explicit mapping A | B absent | B present |
N3 | inherit with A+B in scope | Detect that B may become available | Assume non-declaration blocks it |
N4 | A → B inherit, B → C A only | C sees A, not B | C sees B |
N5 | contents: read | Read succeeds | Required read is impossible |
N6 | contents: read + write attempt | Write fails and no file appears | Write succeeds |
N7 | Protected environment | No secret before applicable rules pass | Secret usable before gate |
N8 | Fork PR | No access to privileged sentinels; release job not reached | Privileged job runs |
N9 | Cross-repository mapping A | Callee sees only the intended channel | B appears without intended transmission |
Engineering inference. Test N3 is intentionally different from N2. It prevents the misleading conclusion, “B is not declared under on.workflow_call.secrets, therefore B is inaccessible.” That statement is false when applicable inheritance grants access.
Engineering inference. Acceptance must record the expected outcome before the run. Otherwise, a team can rationalize an unexpected result after the fact. A successful negative test is valid only for the recorded revision, settings, and scopes. Rerunning it under a different policy or revision creates a new experiment.
Detect over-broad permissions and secret exposure paths
Documented behavior. GitHub recommends the principle of least privilege for workflow credentials and the GITHUB_TOKEN. The documentation also notes that an action can access the token through the github.token context, so explicitly reducing permissions matters even when no application secret is passed directly to the step.
Primary source: GitHub Docs, Secure use reference, page date not stated, accessed September 22, 2026.
Engineering inference. A static audit can identify suspicious paths before the lab. Priority patterns include secrets: inherit, missing permissions, write permissions on a job that does not need them, an environment: hidden in a callee, a cross-repository call on a moving reference, and a second hop passing more secrets than necessary. Each pattern is a review trigger, not automatic proof of a vulnerability.
Pattern | Why the signal matters | Acceptance action | Class |
secrets: inherit | Broadens the secrets that may be available | Replace with mapping or justify | Documented + inference |
permissions absent | Applicable defaults become decisive | Declare an explicit minimum | Documented + inference |
contents: write on a test job | Unnecessary write authority | Reduce to read/none | Inference |
Callee with environment: | May introduce additional secrets/protections | Inventory the environment | Documented |
A → B → C with repeated inherit | Broad retransmission | Review every hop | Documented + inference |
Cross-repository @main | Called code can change independently | Pin the reviewed revision | Inference |
Secret injected into shell command | Increases exposure/manipulation risk | Avoid or encapsulate | Inference |
Verification through masked logs | Confuses availability with display | Use Boolean tests | Documented + inference |
Engineering inference. Also avoid treating the absence of visible ${{ secrets.X }} in YAML as proof that access is absent. A reusable workflow can call a composite action or another reusable workflow, and the token is available through its own context. Follow the execution path.
Resulting artifact integrity is yet another boundary. For that separate control, see GitHub Artifact Attestations: Build a Consumer Release Gate.
Validate fork and untrusted-input boundaries
Documented behavior. For pull requests from forks, GitHub documents that secrets other than GITHUB_TOKEN are not passed to the runner and that the GITHUB_TOKEN is read-only in those pull requests. GitHub also applies these restrictions to Dependabot pull requests.
Primary source: GitHub Docs, Events that trigger workflows, page date not stated, accessed September 22, 2026.
Engineering inference. These defaults must not become the only barrier in a publication chain. The acceptance design must structurally prevent the path fork PR → privileged reusable workflow → release environment. An untrusted change should be able to run necessary checks without reaching a job capable of publishing or deploying. Make that separation visible in both event conditions and secret mapping.
A simple separation keeps PR tests secret-free and reserves the synthetic release job for a trusted event and ref:
name: PR and trusted release boundary
on:
pull_request:
push:
branches: [main]
jobs:
pr-check:
if: github.event_name == 'pull_request'
permissions:
contents: read
uses: ./.github/workflows/reusable-pr-check.yml
synthetic-release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
uses: ./.github/workflows/reusable-release-check.yml
secrets:
approved_secret: ${{ secrets.TEST_SENTINEL_A }}Proposed experiment. Create a fork only from the disposable public repository. Change a harmless file and open a pull request. The expected outcome is that pr-check runs with weak rights while synthetic-release is skipped because its conditions do not match. Configure no real publishing credential.
Origin | Test job | Secret A | Synthetic release job | Expected |
Fork PR | Yes | Not passed to PR job | Not reached | Accept |
Same-repository PR | Yes | Do not pass it under lab policy | Not reached | Accept |
Push to main after reviewed merge | By design | Only if explicitly required | May reach the synthetic gate | Review required |
Privileged event + downloaded fork code | Excluded from the lab | Higher risk | Block | Reject |
Documented behavior. GitHub more broadly warns that a workflow with secrets becomes dangerous if it fetches and then executes untrusted code. The issue therefore goes beyond the trigger name: downloaded or executed material must also be part of the trust model.
For actual npm publishing authority, keep publication governance as a separate topic; Beyond One OIDC Workflow: Governing npm Trusted Publishing at Scale covers that perimeter without turning this secrets lab into a registry test.
Build rollout controls for central reusable workflows
Engineering inference. A central workflow is an operational dependency for multiple callers. Changing its secret contract or permission requirements can therefore change several trust boundaries in a single pull request. Rollout governance should treat the addition of secrets: inherit, an environment, a write permission, or a new nested workflow as a security-interface change.
Documented behavior. GitHub allows reusable workflow chains only when the required workflows are accessible to the initial caller, and permissions cannot be elevated through the chain. This provides a technical ceiling for the token, but it does not decide which secrets a maintainer should pass.
Rollout control | Proposed gate | Class |
Change to workflow_call.secrets | Security review of the contract | Inference |
Switch from mapping → inherit | Blocking change requiring approval | Inference |
New write permission | Justification tied to a specific operation | Inference |
Add an environment | Review environment secrets + protections | Inference |
New nested reusable workflow | Mandatory graph update | Inference |
New cross-repository dependency | Document owners + revision + access | Inference |
Remove a secret | Negative non-availability test | Inference |
Major callee change | Rerun the acceptance matrix | Inference |
Engineering inference. For a shared workflow, publish an operational contract next to the YAML: accepted inputs, expected secrets, minimum permissions, referenced environments, nested calls, and operations explicitly out of scope. This documentation does not replace the executable file; it provides the baseline against which a diff can be judged. Reviewers can then compare a proposed change with an explicit security contract rather than relying on memory.
Engineering inference. A safe rollout starts with a few disposable or unprivileged callers, then expands adoption after comparing observations with expected outcomes. Never turn one successful lab test into a global conclusion about every repository in an organization.
Capture evidence and define acceptance gates
Engineering inference. Good evidence ties behavior to a precise configuration. “The test was green” is not enough. Record the caller commit, callee commit, complete graph, trigger, requested permissions, secret-transmission method, environment, result of each negative test, and any GitHub validation messages. The evidence should make the tested configuration reproducible by another reviewer.
Documented behavior. Masking cannot turn a log into proof that a secret was unavailable. GitHub says masking does not prove non-access because it is performed by the runner and is not guaranteed for every transformation of a value. A log showing *** establishes at most that a string was masked; it certainly does not prove that the step never received the string.
Evidence | Example | Gate |
Caller SHA | Reviewed lab-caller commit | Required |
Callee SHA | Actually referenced commit | Required |
Graph | A → B → C | Required when nested |
Secret edge | A → approved_secret | Required |
Mode | explicit / inherit | Required |
Permissions | contents: read | Required |
Environment | none / lab-approval | Required |
Negative secret test | B absent in explicit case | Must match expectation |
Negative token test | Write denied | Must match expectation |
Fork | Release job not reached | Required for affected workflow |
Logs | No synthetic value in clear text | Required |
Conclusion scope | “tested configuration only” | Required |
Proposed experiment. Prepare the ledger before every run:
Case ID:
Caller SHA:
Callee SHA:
Nested callees:
Repository visibility:
Event:
Secret mode:
Expected available sentinels:
Expected unavailable sentinels:
Caller permissions:
Callee permissions:
Environment:
Expected write operations:
Expected denied operations:
Expected outcome:
Observed outcome:
Evidence links:
Reviewer:
Decision:Engineering inference. An Accept decision requires observations to match every critical expectation. Reject applies when a secret or write appears where the model forbids it. Hold applies when a result is ambiguous, an organization policy is unknown, a callee has changed, or the evidence cannot precisely attribute behavior to the audited revision.
Workflow-authorization evidence does not replace evidence about the final artifact. Keep this ledger separate from any artifact-attestation gate.
Decision scope (engineering inference). Acceptance under this playbook covers only workflow_call behavior, secrets, inputs, permissions, environments, nesting, cross-repository use, and untrusted-input boundaries. It certifies neither caches, cloud identity, artifact integrity, the publishing registry, nor the runner.
Final acceptance decision and Refonte Learning CTA
Decision | Conditions | Class |
ACCEPT | Mapping matches the contract; negative-case secrets are absent where required; permissions are minimal; the negative write is blocked; environments are inventoried; nesting and cross-repository use are bounded; a fork cannot reach the privileged job | Engineering inference |
RESTRICT | Workflow is useful, but inheritance, permissions, or environment exceeds the demonstrated need | Engineering inference |
HOLD | Revision, ownership, secret scope, environment, GitHub policy, or negative result is ambiguous | Engineering inference |
REJECT | An unauthorized secret is available, scope is broadened without justification, a forbidden write succeeds, or an untrusted path reaches a privileged job | Engineering inference |
The Refonte Learning DevOps Engineer program runs for 3 months at 12–14 hours per week and covers Linux/scripting, Git/GitHub, CI/CD, Docker/Kubernetes, and Terraform.
It also covers AWS/Azure/GCP, monitoring/logging, and a capstone project; see the program page for details.
