CI security / Runnerless Stories
Why your publishing service should pull artifacts
Published
Sources reviewed
When a build job pushes its output straight to a registry, bucket, or site, it holds the destination credential and chooses which bytes go out. A safer handoff gives those responsibilities to a separate publishing service: the build stores its output in CI, and the publisher fetches the permitted artifact, verifies it, and publishes it.
On this page
The artifact handoff
Consider a pipeline whose last step is a direct push. A job runs the project's own code, then uses a registry token or deploy key to write straight to the destination. Two problems ride along. The credential sits inside the job, beside dependency installs and pull request code. Without independent verification, the destination receives bytes plus the uploader's claim that they came from an approved commit.
Turn the handoff around. The build job keeps only the narrow ability to store output in the provider's artifact storage. A publishing service, holding destination write credentials outside build code, is told which build to look at. It retrieves that build's output from a source it already trusts, checks the run against the repository, commit, and file it expects, and only then publishes. The build produces. The publisher selects, verifies, and publishes.
Direct push
build job [holds registry credential]
pushes bytes + claim "built from main" => registry / bucket
Publisher fetch
build job [holds artifact-upload scope only]
stores output => CI artifact storage
publisher [holds destination credential]
fetches <repo>/<run>/<artifact> from the configured CI API
checks run status, commit, permitted file, digest of the bytes
publishes verified bytes => configured destination
The callback
The UK Financial Conduct Authority's advice on bank scams is simple: if a suspicious caller claims to be your bank, hang up and phone the number printed on your card, not a number the caller gives you. Use contact details you already trust to verify the claim independently. The publishing service applies the same rule. It does not accept an artifact because an upload arrived claiming to be from build 4187. It calls the configured CI provider API, asks for build 4187 directly, then checks the returned build record and bytes against its publication policy.
The Hollywood principle
Martin Fowler describes inversion of control with the Hollywood line "don't call us, we'll call you": a framework calls your extension code rather than your code driving the framework. That is a design principle about who holds control flow, not a security proof, and it says nothing about network direction. The useful part for artifact publishing is the inversion itself. The publisher's contract with a build is: "Don't upload arbitrary bytes to me. Tell me which approved build to inspect, and I'll fetch its output." Control over what gets published moves from the sender to the receiver.
Push and pull in CI
A notification, such as a workflow completion webhook, can start the publisher, but it only says "look at this run". Authenticate notifications as the integration requires, but treat a notification as a prompt to check, not permission to publish. The publisher then works through a fixed sequence.
- Resolve the trigger to a repository, run, and artifact name, and check the repository and workflow against the allowlist.
- Read the build record from the provider API: the run succeeded, and its head commit matches the branch or pull request the publisher is configured for.
- List that run's artifacts and select the permitted artifact, recording its identity, digest and size. Names alone do not establish which bytes may be published.
- Check that protected publication policy permits this repository, run, commit, artifact digest and destination. For a gated release, verify that a current approval covers that exact combination; a provider-reported digest alone is not approval.
- Download through the configured source adapter with a size limit and a timeout.
- Hash the downloaded bytes and compare them to the approved digest. A mismatch stops everything.
- Treat the content as untrusted data: unpack archives with path and size checks, never run scripts found inside, never let the artifact choose a credential.
- Publish exactly those bytes to the configured destination, using a durable operation record and destination idempotency where supported, so retries cannot silently publish a different artifact or repeat a completed action.
GitHub's artifact API shows what such a source offers: each artifact carries an id, a name, a digest, and its workflow run with repository ids and head SHA, and downloads use the repository and artifact id under an Actions read scope. The publisher reads source records independently and applies its own protected policy. Each pipeline still needs to produce and store the agreed artifact, and the integration needs configuration. It no longer needs final-destination credentials or its own upload logic. Source verification, byte and size limits, retries, permissions, and retention live in one service instead of being copied into every workflow.
HTTPS authenticates a name
Fetching from the provider over HTTPS is necessary and limited. RFC 9525 describes what a TLS certificate proves: the server you reached is entitled to the service name you asked for. With certificate and hostname checks enabled, the publisher authenticates its configured source, such as api.ci.example. HTTPS does not say the bytes are safe, that the code was reviewed, or that the path means what the publisher assumes. Publication decisions need the checks above: the build record, the permitted file, and the digest bound to the downloaded bytes. Where a build system supplies signed provenance, the SLSA verification model applies: check the signature against a trusted builder, confirm the digest matches the artifact, and confirm source and build parameters meet expectations.
The URL as a contract
The retrieval URL is part of the contract, so the publisher constructs it rather than accepting it.
GET https://api.ci.example/repos/acme/site/runs/4187/artifacts/site-dist
Accept: application/zip
expected: digest from run 4187 covered by publication policy or approval
limit: at most 20 MB
destination: pages bucket acme-site-prod, from publisher configuration
Method, path, content type, size limit, and approved digest define what will be accepted. A URL or filename is not evidence, and an uploader-supplied hash is only a claim. The publisher hashes what it actually downloaded against the digest independently obtained from the source and permitted by policy or approval, before anything becomes publicly visible, and publishes those same bytes.
It never fetches a URL a caller hands it. Blindly fetching caller-supplied URLs can enable server-side request forgery (SSRF), and the OWASP guidance is to allowlist destinations, check resolved addresses, and disable redirects that could bypass those checks. The publisher takes a validated repository, run, and artifact id and resolves them through a source adapter that uses configured trusted origins and restricts its outbound network access.
Redirects still need care. GitHub’s documented artifact download endpoint returns a redirect to an expiring download URL. The adapter validates that destination, does not forward the source Authorization header to it, and treats the signed URL as a credential that stays out of logs.
Who owns the destination
| Publication concern | Simple direct build push | Publisher retrieves and verifies |
|---|---|---|
| Final write credential | In the publishing job, accessible to code running with its permissions | Only in the publishing service |
| Source and build evidence | Uploader claims unless independently verified | Read from the provider: run status, repository, head commit |
| Permitted file and destination | Whatever the job pushes, wherever its token allows | Policy-approved artifact and digest to a configured destination |
| Policy, limits, retries | Reimplemented per pipeline | Centralized in one service |
Direct upload can be made safe. An intake service that holds the destination credential itself, requires signed provenance or a provider-attested digest, and enforces the same repository, run, and file checks isolates credentials just as well. Pull is preferred here because fetching from a configured trusted source and reading independent build records are the natural shape of the design, not because retrieval is always safer.
Risks remain. The publisher holds valuable credentials; protect its policy, runtime, parsers and network access. A compromised build can still produce a malicious artifact for its own commit; checked source records and digests bind the bytes to the selected build; they do not establish that its output is safe. Review and any required provenance verification or content scanning still matter.
This is how artifact publishing already works in the private ci-toolkit BuildBuddy artifact publisher: it verifies the invocation origin, that the build succeeded, and that the repository, pull request, and current head match, then retrieves the declared outputs before publishing reports and files. That is report and file publication, not a human-approved release. The public Runnerless runtime and the isolated general action API remain planned, with this separation informing their design.
Give build jobs enough authority to store their output. Let the publishing service retrieve that output independently and decide, under its own policy, exactly what reaches the destination.