7 min read
CI egress policy should follow workflow trust
A runner pool is not a trust boundary
A test job that runs untrusted pull-request code and a release job that publishes artifacts should not reach the same destinations just because both use the same runner pool. The pool tells me where a job can run; it does not tell me what that job ought to be able to contact.
That distinction matters because outbound access is a capability. A compromised action or dependency can use it to reach a host the workflow author never intended. GitHub’s 2026 Actions security roadmap calls out unrestricted outbound access on GitHub-hosted runners and describes a native egress firewall as a way to observe and control it. The roadmap discussion in GitHub Community also includes a request for rules scoped per workflow, rather than only global policy.
I agree with the direction of that request. I would not start by asking, “Which runners need access to this host?” I would ask, “Which job purpose needs access, and why?” A test job may need package registries and a test service. A release job may need an artifact store or deployment endpoint. Giving both jobs the union of those destinations is convenient, but it turns convenience into a standing permission.
This is not an argument that every job needs a unique firewall rule. Fine-grained policy can become so costly to maintain that people disable it or add broad exceptions. The goal is a small number of understandable trust profiles, with each profile tied to work the job actually performs.
Map dependencies before enforcing rules
I would first make a dependency map from observed traffic and workflow definitions. For each job type, record the hosts it contacts, the reason for each connection, and whether the dependency is direct or introduced by a tool, action, package manager, or container build. The map should include the less obvious destinations: a package manager’s mirror, an identity provider, an artifact registry, or a service used only during setup.
The reason matters as much as the hostname. “The job connects to this registry” is a starting point, not a policy justification. Is it fetching test dependencies, pulling a build image, or publishing a release artifact? If the same host supports both downloading and publishing, the network allowlist alone may not distinguish those actions. I would then look for other controls—credentials scoped to the job, repository permissions, or a separate publishing step—rather than pretending a domain rule provides finer control than it does.
I would classify jobs by purpose before choosing policy profiles. For example, tests, builds, and releases are useful starting categories, but I would split them further only when their required destinations or trust differ materially. A test job that runs code from an untrusted contribution deserves different treatment from a test job that runs only after code is trusted. The label “test” does not make them equivalent.
The first pass should be observational, not a block-everything launch. The roadmap describes a monitor mode intended to help teams understand runner traffic before enforcement. I would use that visibility to discover dependencies, then check whether each observed connection is legitimate. A traffic log is evidence of what happened, not proof that the connection should remain allowed.
Once I have that map, I can define the policy in terms people can review: this job profile may fetch build dependencies, while that profile may additionally publish to the release destination. I would keep the explanation next to the policy. A bare list of hostnames decays quickly because maintainers cannot tell whether an entry is essential, stale, or an accidental workaround.
Put the policy where it can be maintained
A reusable workflow is a good place to standardize job purpose and invocation across repositories. GitHub documents reusable workflows as callable workflows, with inputs and secrets passed by the caller; the reusable workflows guide explains that model. I can use a shared workflow to keep the job definition consistent, but I should not assume that reusing YAML automatically creates an egress boundary. The runner infrastructure still needs to enforce the network policy associated with the job.
For self-hosted runners, I would express the distinction in runner placement and apply network controls outside the workflow process where possible. GitHub’s self-hosted runner security guidance recommends reducing runner exposure and using runner groups to limit which repositories can access runners. Those controls help determine who may schedule work onto a runner; they are not a substitute for restricting what that runner can reach over the network.
An illustrative caller might make the job’s purpose visible in the runner labels and reusable workflow it invokes:
name: Build and release
on:
push:
branches:
- main
permissions:
contents: read
jobs:
test:
uses: example-org/engineering-workflows/.github/workflows/test.yml@main
with:
runner_class: ci-test
release:
needs: test
uses: example-org/engineering-workflows/.github/workflows/release.yml@main
with:
runner_class: ci-release
permissions:
contents: read
packages: write
The labels here are an organizational example, not a built-in firewall feature. I would make them select runner pools whose network policy is deliberately different, and ensure the workflow caller cannot quietly choose a more privileged pool without review. In a real setup, I would also pin shared workflow references to reviewed revisions rather than letting an unreviewed change silently alter every caller. The important property is not the string in runner_class; it is the enforceable relationship between job purpose, runner assignment, and outbound policy.
Reusable workflows also have to avoid turning centralization into a blind spot. A shared workflow can hide network calls inside actions or scripts, so I would make its required destinations visible and reviewed as part of the shared contract. When a workflow changes its dependencies, that change should trigger a policy review, not be discovered only after a blocked build.
Keep enforcement strict and changes survivable
The common failure mode is an allowlist that is either too broad to matter or so brittle that normal maintenance repeatedly breaks builds. I would avoid both by separating discovery from enforcement and giving policy changes an explicit owner. For a newly required destination, the job should fail with enough context to identify the workflow and attempted connection; the fix should be a reviewed policy change, not a hurried global exception.
I would also watch for policy drift. If a destination has not appeared in observed traffic for a while, that is a reason to ask whether it is still needed, not to remove it blindly. A rarely run release workflow may still need its publishing endpoint. Conversely, an endpoint that appears in every run may be an unnecessary dependency carried by a tool. The policy review should consider purpose and workflow frequency, not just traffic volume.
There is a trade-off in where to enforce. Runner-level rules are easier to administer and useful as a coarse baseline, especially for self-hosted infrastructure. But if one pool serves jobs with different trust and responsibilities, a single permissive rule grants every job the union of their access. Workflow- or job-aware policy can make narrower permissions possible, but it requires dependable identity, usable logs, and a manageable way to keep shared workflow definitions aligned with infrastructure policy.
I would therefore keep a baseline at the runner or network-zone level, then separate pools or policy profiles where job responsibilities genuinely differ. I would not create a bespoke rule for every repository when a shared test or release profile expresses the same need. And I would not treat GitHub’s announced firewall as a complete answer for self-hosted runners: the roadmap describes the native capability for GitHub-hosted runners, while teams running their own infrastructure still need to enforce and maintain their own network boundaries.
The design test is simple: if I move a job from testing to publishing, does its outbound capability change because its responsibility changed, or does it inherit whatever the runner pool happens to allow? If the answer is the latter, runner identity is doing too much work. Egress policy should follow the trust and purpose of the workflow, with the runner acting as the enforcement point—not as the definition of trust.
Drafted with AI assistance, reviewed and edited by Ali.