This article focuses exclusively on security boundaries, troubleshooting, reliability, and cost control in GitHub Actions CI. For the basic execution model, including Workflows, Events, Jobs, Runners, Matrices, and Caches, see 04: The GitHub Actions execution model; for designing push/PR CI, Required Checks, and Rulesets around local checks, see 05: Configuring CI with GitHub Actions.
CI risks go beyond whether passwords are written into YAML. A Pull Request can change test code, build scripts, dependencies, and workflows, all of which may ultimately execute on a Runner. Review four boundaries together: the trust level of the event, GITHUB_TOKEN permissions, Secrets/external credentials, and the lifecycles of Runners and caches.
1. Map the Trust Boundaries First#
1.1 Events, Code, and Permissions Are Different Things
| Subject | Questions to Ask First | Safe Default Approach |
|---|---|---|
push to a protected branch |
Are the committer and branch governed by repository rules? | Can serve as an entry point for trusted builds or cache population, but still requires least privilege |
pull_request |
Does the PR come from a Fork? Which contributor-supplied code will the workflow execute? | Treat the head, scripts, and dependencies as untrusted; normally do not give Fork PRs Secrets or write permissions |
pull_request_target |
Does this actually require the base repository context, Secrets, or write access? | A high-risk privileged event; do not check out and execute untrusted PR code |
workflow_run |
Are the artifacts, outputs, and metadata uploaded by the preceding Workflow trustworthy? | Can provide a privilege-separation boundary, but downstream workflows must treat upstream artifacts as untrusted input |
workflow_dispatch |
Who can trigger it manually? Will the inputs reach a shell or deployment? | Restrict who can trigger it and which environments it can use; still validate inputs |
The purpose of a pull_request check is to validate an untrusted change, not to give that change control of the repository. Needing to install dependencies or run build scripts does not justify granting it write permissions, deployment Secrets, or a self-hosted Runner.
1.2 GitHub-hosted and Self-hosted Runners
- GitHub-hosted Runners are managed by GitHub, and standard Jobs typically run in isolated, ephemeral execution environments. Still treat workflows and dependencies as programs that may execute arbitrary code, and never print Secrets directly or write them into artifacts or caches.
- Self-hosted Runners are machines you manage yourself and may retain working directories, credentials, network access, and system state. GitHub does not guarantee a clean machine for every Job. Fork PRs in public repositories can send dangerous code to these machines, so do not route untrusted PRs from public repositories directly to Runners with internal-network or production access.
- If you genuinely need self-hosted Runners, restrict which repositories can use each Runner group, isolate networking and permissions, prefer ephemeral Runners that are destroyed after every Job, and review cleanup, update, and offline-recovery procedures.
Choosing a Runner is a security decision, not just a performance or pricing decision. GitHub-hosted per-minute charges are no substitute for isolation, and self-hosted “free minutes” do not mean the infrastructure is free or secure.
2. GITHUB_TOKEN and Least Privilege#
2.1 What the Token Is
Before each Workflow Job starts, GitHub creates a short-lived GITHUB_TOKEN for that Job. It is an installation token for the GitHub App installed on the repository, scoped to the repository containing the Workflow, and becomes invalid when the Job ends or the token expires. Even without an explicit token input, an Action may access it through the github.token context, so reviewing only the parameters under with: is not enough.
Apply least privilege in this order: first avoid needing a Token at all; when one is necessary, explicitly list the minimum permissions at Workflow or Job level; increase permissions only on the specific Job that actually needs write access.
2.2 The Meaning of permissions
Once permissions is explicitly specified in a Workflow or Job, any permission not listed is treated as none. Do not assume that an omitted permission inherits a safe default; add permissions individually according to the actual steps. Job-level settings can restrict or grant the permissions a particular Job needs. Deployment Jobs and ordinary test Jobs should not share a broad set of permissions.
A read-only Nix build usually needs only contents: read:
name: CI
on:
pull_request:
push:
branches: [main]
# 显式声明:未列出的 GITHUB_TOKEN 权限为 none。
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run checks
run: ./ci/check.shIf only a release or deployment Job needs extra permissions, specify them on that Job rather than at the top level of the Workflow:
jobs:
test:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- run: ./ci/test.sh
publish:
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
steps:
- run: ./ci/publish.shpackages: write is only an example; it does not establish the permission requirements of every publishing tool. Document every permission increase in the PR description, explaining which step needs it, whether the triggering event is trusted, and how to revoke it.
2.3 Default Boundaries for Fork PRs
Fork PRs in public repositories normally do not receive repository, organization, or environment Secrets, and their GITHUB_TOKEN normally has read-only permissions. Workflows from first-time contributors may also require maintainer approval. Similar Fork restrictions apply to Dependabot PRs. Private repositories can configure more permissive Fork policies in Settings, but that is an explicit risk decision and should not be assumed as the default.
When a Fork PR encounters a 403, a missing Secret, or an inability to create checks, do not change pull_request to pull_request_target or enable policies that send write Tokens/Secrets simply to make CI green. First determine whether that step should have write access at all.
3. Secrets, Variables, and Environments#
3.1 Boundaries Between the Three Configuration Types
| Type | Purpose | Can It Be Treated as Confidential? |
|---|---|---|
| Repository/Organization Secret | Sensitive values such as Tokens, API keys, and signing material | Yes; grant access at the narrowest scope and rotate regularly |
| Environment Secret | Sensitive values specific to a deployment environment | Yes; protect with Required reviewers, branch rules, and wait timers |
Variable(vars) |
Non-sensitive configuration such as region names, feature flags, and version tags | No; visible in logs and workflow code, so do not store credentials here |
Pass Secrets to the steps that need them through ${{ secrets.NAME }}, and use them only in trusted Jobs:
jobs:
deploy:
# production 环境需在仓库 Settings 中配置保护规则和 Secret。
environment:
name: production
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: ./ci/deploy.shDEPLOY_KEY in this example is merely a placeholder name, not an instruction to create that Secret in the repository. Never substitute real values into documentation or commits. Do not output Secrets through echo, set -x, debug printing, artifacts, caches, or temporary files. GitHub's automatic redaction is not a security boundary: encoding, slicing, concatenation, or tool transformations may prevent a Secret from matching its original value.
3.2 An Environment Is an Approval Boundary, Not a Sandbox
When a Job references an Environment, it does not begin execution until the protection rules pass. Environment Secrets become accessible only after the Job is approved and starts. You can restrict which branches or tags may deploy, require human review, set wait timers, or integrate protection rules.
An Environment does not turn untrusted code into trusted code or a self-hosted Runner into an isolated container. If untrusted code can ultimately run on the same Runner, it may still read the environment, working directory, or other visible resources. Separate testing of untrusted PRs from deployment using production Secrets into distinct Workflows/Jobs, and make the latter respond only to controlled events.
4. OIDC and Short-lived Cloud Credentials#
OpenID Connect (OIDC) allows a cloud provider to exchange a short-lived identity token issued by GitHub for a short-lived cloud access token, reducing the need to store long-lived cloud keys in GitHub Secrets. It does not grant authorization automatically: the cloud provider must still configure trusted subjects, the Audience, and repository/branch/Environment conditions, while restricting the cloud role itself to the minimum necessary operations.
Requesting an OIDC JWT requires id-token: write. The name can be misleading: it only allows the Workflow to request an identity token from the GitHub OIDC provider; it does not grant write access to cloud resources or to the GitHub repository. This example illustrates only safe permission and environment boundaries, without real cloud credentials or provider Secrets that could be used directly:
jobs:
deploy:
environment:
name: production
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Exchange OIDC token with the cloud provider
# 用提供商的官方 Action,并固定到经过审查的完整 commit SHA。
uses: provider/official-login-action@<40-hex-commit-sha>
with:
audience: example-audience
- run: ./ci/deploy.shWhen configuring the cloud-side trust policy, check at least the following:
- Does
sub/subject restrict access to the intended repository, branch, or Environment, rather than accepting any repository or branch? - Does
aud/audience match the provider configuration, preventing the token from being used for a different purpose? - Could Fork PRs,
pull_request_target, orworkflow_runsatisfy these trust conditions? Untrusted events should not receive production roles. - Does the cloud role allow only the necessary API operations, with independent revocation and audit paths?
The teaching example for this article's theme project only runs Nix CI and has no cloud deployment requirement. Do not add id-token: write just to demonstrate OIDC.
5. Third-party Actions, Reusable Workflows, and the Supply Chain#
5.1 Pin Immutable SHAs
Tags such as @v4 make upgrades convenient, but tags can move. For auditable, reproducible Workflows, pin third-party Actions to a full 40-character commit SHA and retain the human-readable version in a comment:
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Nix
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1SHA pinning only prevents a tag from silently pointing to a different commit in the future; it does not prove that the pinned commit is safe. Before use, still review the repository owner, the Release/Tag mapping, source code, permissions, and maintenance status. For critical Actions, restrict the Actions/Reusable Workflows allowed in repository Settings and use Dependabot or regular manual updates.
Reusable Workflows are also a supply-chain boundary: callers should pin org/repo/.github/workflows/reuse.yml@<SHA> and review the called Workflow's permissions, inherited Secrets, inputs, and outputs. Do not assume it is trustworthy merely because it looks like a template from your own repository. Specify the caller's required permissions explicitly to avoid inadvertently passing broad permissions into a shared workflow.
5.2 Inputs, Scripts, and Command Injection
Issue titles, PR titles/bodies, branch names, commit messages, paths, and manual inputs may all be controlled by contributors. Do not interpolate these context values directly into a run: string:
# 不安全:输入先被插入生成的 shell 脚本。
# run: echo "${{ github.event.pull_request.title }}"
- name: Print untrusted title safely
if: github.event_name == 'pull_request'
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
printf '%s\n' "$PR_TITLE"Pass values to scripts through env and double-quote variable expansions inside the script. When passing paths to Git or other tools, use -- to separate options from paths, and reject unexpected formats. For more complex parsing, prefer a reviewed Action that accepts context values as parameters instead of constructing shell commands from them.
6. pull_request_target, workflow_run, and Untrusted Code#
6.1 pull_request and pull_request_target
pull_request is suitable for read-only checks that run untrusted code, with the trade-off that Fork PRs normally cannot use Secrets or write permissions. pull_request_target runs in the context of the base repository's default branch, according to the current official event reference, and may receive the base Workflow, Secrets, and elevated GITHUB_TOKEN permissions. It is therefore not a safe alternative for making Fork CI work.
A high-risk pattern is checking out a Fork's head in pull_request_target and then running its build scripts, tests, or dependency installation. This executes untrusted code in a privileged context, potentially allowing it to read credentials or modify the repository. Do not put such builds or dependency installations in privileged events. Ordinarily, keep read-only tests on pull_request and separate controlled releases into post-merge push events or approved deployment Jobs.
6.2 workflow_run Separates Permissions, Not Trust
workflow_run can launch a subsequent Workflow with different permissions after a low-privilege test Workflow completes. This helps separate testing from publishing, but upstream artifacts, caches, outputs, and filenames may still be influenced by untrusted commits. Downstream workflows should not directly check out or execute scripts uploaded upstream, or treat unvalidated outputs as commands, paths, or identity conditions.
One possible boundary is to let the upstream workflow upload only results in an explicitly defined format, then have the downstream workflow revalidate the artifact's origin, hash, structure, and allowed file set. Use OIDC or Environment credentials only after protected-branch, approval, and check-status requirements are satisfied. If no data needs to pass between Workflows, the simplest safe approach is to avoid introducing workflow_run.
6.3 Cache poisoning
Caches are shared within scopes such as branches or tags; they are not independently isolated by Workflow/Job identity. A run that can read a Cache restores its contents as stored. Never cache Secrets, signing material, authentication directories, or executables whose use depends on a trust boundary. A malicious or poisoned cache can cause a trusted Workflow to execute attacker-supplied files later.
Fork PRs and pull_request_target are subject to GitHub's cache-access restrictions, but those restrictions do not replace review. Restoring executable build outputs from an untrusted Cache into a trusted Job remains dangerous. When populating a cache, prefer generating it in a trusted push Workflow. Treat restored contents as untrusted input every time, using lockfiles, hash-based keys, and designs that support rebuilding from scratch.
7. Troubleshooting Failures and Debugging Safely#
7.1 Identify the Failing Layer First
Use a consistent troubleshooting order to avoid repeatedly rerunning jobs blindly:
- Trigger layer: the event, branch and path filters, whether the Workflow file is at the expected ref, and whether Fork approval is complete.
- Permission layer: Workflow/Job
permissions, the read-only Fork boundary, Environment approvals, and Secret visibility. - Runner layer: whether the Runner is queued or offline, out of disk space, experiencing network/DNS failures, or using changed tool versions.
- Action/dependency layer: whether the SHA exists, Action inputs have changed, upstream downloads have failed, or cache hits are unexpected.
- Project-check layer: failures in Nix evaluation, builds, tests, or the output contract itself.
- Concurrency/cost layer: cancellation by concurrency controls, timeouts, or blocks caused by budget, minute, or storage limits.
First inspect the failed Job's earliest error and the context immediately preceding it, then read the full logs. Only then decide whether to fix it, rerun it, or treat it as a transient infrastructure issue. Rerunning does not fix deterministic YAML, permission, input, or code errors.
7.2 A Minimal GitHub CLI Troubleshooting Flow
gh run list --workflow nix.yml --limit 10
gh run view RUN_ID
gh run view RUN_ID --log-failed
gh run watch RUN_ID
gh run rerun RUN_ID --failedFor additional Runner/Step diagnostics, you can use the following on a rerun:
gh run rerun RUN_ID --failed --debugYou can also temporarily enable ACTIONS_STEP_DEBUG=true and ACTIONS_RUNNER_DEBUG=true in repository or organization settings. Debug logs may expose additional paths, command arguments, dependencies, and environment information. Before enabling them, check whether credentials appear on command lines, and promptly remove or disable the debug variables afterward. Whether logs come from the UI or gh, never copy logs containing Secrets, OIDC tokens, private URLs, or internal-network information into a public Issue.
7.3 Common Symptoms and Next Steps
| Symptom | Check First | What Not to Do First |
|---|---|---|
| Workflow did not run | Events, branch/path filters, the Workflow's ref, Fork approval, and Actions Settings | Do not casually broaden triggers to every branch |
403 / Resource not accessible by integration |
The Job's permissions, event type, read-only Fork boundary, and target API |
Do not jump straight to global write-all |
| Secret is empty | The Secret's scope, Environment approval, and Fork/Dependabot restrictions | Do not print the Secret to check it |
| Action cannot be found or its behavior changes unexpectedly | Whether the SHA is complete, the commit exists, and Action inputs or the Runner image have changed | Do not simply switch to a moving @main |
| The same commit fails intermittently | Download services, Runner networking, cache hits, and concurrency cancellations | Do not hide deterministic errors behind endless reruns |
| Job was canceled or timed out | concurrency, timeout-minutes, queues, and cost limits |
Do not increase the timeout indefinitely |
| Self-hosted Runner is offline | The Runner service, labels/groups, networking, and disk space | Do not add a new repository directly to a shared Runner |
For a failed PR, you can rerun the original commit and event. After fixing the Workflow, push a new commit so that a configuration fix can be distinguished from an infrastructure retry.
8. Timeouts, Concurrency, and Cost#
8.1 Set Job Limits
Set timeout-minutes to an upper bound appropriate for the actual workload, preventing infinite loops, stalled downloads, or broken builds from monopolizing a Runner:
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: nix flake check --all-systems --no-buildA timeout should prompt investigation of slow steps, not substitute for caching, splitting Jobs, or fixing runaway scripts. When setting a deployment Job's timeout, also define whether canceling deployment can leave a partially completed state and how to roll back.
8.2 Cancel Only Outdated CI Runs
Use concurrency to cancel older checks for the same PR and save minutes:
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: trueThis policy suits repeatable PR CI with no external side effects. Do not apply it directly to releases, migrations, or irreversible deployments. Production deployments are better serialized by Environment, with cancellation replaced by reviewed queuing/mutual-exclusion rules.
8.3 Priorities for Cost Control
- First reduce unnecessary runs with
branches,paths, and appropriate events, then consider more complex matrices or caching. - Give matrices clear axes and failure policies; do not duplicate a set of checks for every platform if those checks never actually run.
- Use timeouts to prevent abnormally long Jobs, set artifact/log retention periods, and periodically clean up outputs that are no longer needed.
- Cache only rebuildable dependencies. A cache hit is neither a prerequisite for correctness nor a security boundary. The minutes saved by caching are not worth the risk of contaminating trusted builds.
- GitHub-hosted usage primarily consumes Actions minutes and artifact/cache storage. Self-hosted Runners may not incur the same hosted-minute charges, but you bear the cost of machines, disks, networking, maintenance, and security isolation.
9. Example: Boundaries of Read-only Theme CI#
The following describes the teaching configuration for an anonymized theme CI example. It does not verify the current state of a personal project or report actual runs:
- It triggers on
pushandpull_request, with no branch or path restrictions in the YAML. - The Workflow already sets
permissions: contents: readat the top level, without granting additional write permissions to the build Job. actions/checkoutis pinned to the full SHA3d3c42e5aac5ba805825da76410c181273ba90b1, withv7.0.1noted in a comment.cachix/install-nix-actionis pinned to the full SHA13d8dd58da0234aa297dedd986986ccb8e7f3e24, withv31.11.1noted in a comment.- The example file has no Secrets, OIDC, dependency Cache, or concurrency configuration. Do not describe the examples in this article as capabilities already present in that example.
- The workflow runs
nix flake check --all-systems --no-build, builds six theme variants, and builds thechecks.x86_64-linux.variantsoutput contract.
The example CI's security baseline is therefore read-only repository content, pinned versions of the Actions it uses, and validation of Nix outputs on GitHub-hosted ubuntu-latest. It validates Nix evaluation, builds, and output contracts. It does not establish that Fcitx5, Hyprland, niri, KWin, or GNOME runtime behavior has been tested in CI. In particular, a passing Nix build must not be described as passing Fcitx5/Hyprland runtime tests.
This read-only build example does not need Secrets, OIDC, or self-hosted Runners merely to satisfy a security checklist. If a Cache is added later, cache only rebuildable Nix/dependency content and treat contents restored from Fork PRs as untrusted. If releases or cloud deployments are added later, separate the Jobs, retain contents: read for test Jobs, and design OIDC trust conditions specifically for the protected Environment. Concurrency is an optional cost optimization, not an existing capability of the example Workflow.
10. Security and Reliability Checklist#
- Every Workflow/Job has explicit, minimal
permissions; unlisted permissions are checked asnone. -
GITHUB_TOKENis used only when genuinely needed, and ordinary test Jobs are not grantedwrite. - Fork PRs, Dependabot PRs, and manual inputs are all treated as untrusted input.
- No untrusted PR code is checked out and executed in
pull_request_targetorworkflow_run. - PR titles, bodies, branch names, paths, and
workflow_dispatchinputs are not interpolated directly into shell commands. - Secrets do not appear in logs, artifacts, caches, commits, temporary files, or error messages; Variables contain no credentials.
- Environment Secrets have branch restrictions, Required reviewers, or protection rules appropriate to the risk.
- OIDC
id-token: writeis granted only to Jobs that need it, with cloud-side subject, audience, and role permissions all restricted. - Third-party Actions and Reusable Workflows are pinned to full commit SHAs, and version comments match those commits.
- Caches contain only rebuildable content, and cache keys and restore paths do not introduce untrusted content into trusted execution.
- Untrusted PRs from public repositories do not use self-hosted Runners with internal-network or production access.
- Troubleshooting first distinguishes trigger, permission, Runner, Action, project-check, and cost layers before deciding whether to rerun.
- One-off debug logging is disabled immediately after use, and logs are checked for credentials and internal information before publication.
- Long Jobs have reasonable timeouts;
cancel-in-progressis used only for PR CI that can safely be canceled. - Actions Settings, the allowed Actions, artifact/log retention periods, and Runner groups are reviewed regularly.
11. Official Resources#
- Secure use reference: security practices for Workflows, Secrets, third-party Actions, untrusted checkouts, and script injection.
GITHUB_TOKEN: token lifecycle, scope, and triggering behavior.- Secrets reference: reading, redacting, scoping, and restricting Secrets.
- OpenID Connect reference: OIDC claims, subject, audience, and
id-token: write. - Deployment environments: Environments, protection rules, and environment Secrets.
- Managing GitHub Actions settings for a repository: allowed Actions, Fork policies, default Token permissions, Cache settings, and retention settings.
- Dependency caching: cache scope, cache security, and poisoning risks.
- Events that trigger workflows: Fork PRs,
pull_request_target, and event-trigger semantics. - Troubleshooting workflows and Enabling debug logging: logs, reruns, Runner/Step debugging, and billing troubleshooting.
- Self-hosted runners: maintenance and security boundaries for self-hosted Runners.
The full SHAs were checked against the version comments using the upstream tag APIs on 2026-10-08: actions/checkout v7.0.1 and cachix/install-nix-action v31.11.1. These are the fixed versions used in this article's examples, not a claim that they are the latest or vulnerability-free. cachix/install-nix-action is a third-party Action. Continue tracking upstream releases and security advisories after pinning a SHA.
References & further reading
- Secure use reference ↗
- GITHUB_TOKEN ↗
- Secrets reference ↗
- OpenID Connect reference ↗
- Deployment environments ↗
- Managing GitHub Actions settings for a repository ↗
- Dependency caching ↗
- Events that trigger workflows ↗
- Troubleshooting workflows ↗
- Enabling debug logging ↗
- Self-hosted runners ↗
- actions/checkout v7.0.1 ↗
- cachix/install-nix-action v31.11.1 (third-party) ↗
Adapted from technical notes. Examples use generic names.