Sharing CI/CD Build Artifacts Securely
By SRIFT maintainers · Published
Reviewed by SRIFT maintainers · last reviewed against the SRIFT 4.3.2 source · third-party facts checked 8 October 2026.
Short answer
There are three ways to hand a build artifact from a CI job to a person: the CI system's own artifact store (GitHub Actions keeps artifacts for 90 days by default and requires a signed-in user with read access; GitLab expires them after the instance default, 30 days unless changed), object storage with a presigned URL or a release asset, or a short-lived link streamed from the job itself, which keeps no copy but requires the job to stay alive until the download finishes.
Whichever you choose, treat the link as a secret, keep it out of build logs (public CI logs can be read by anyone), and publish a SHA-256 checksum so the recipient can check what arrived.
- GitHub Actions artifacts
- 90 days by default; sign-in and read access required
- GitLab job artifacts
- Instance default (30 days unless changed); latest kept
- Presigned S3 URL
- Up to 7 days, less with temporary credentials
- Streamed link from the job
- No stored copy; job must wait for the download
The CI system's own artifact store
Every CI platform can keep files produced by a job. This is the right default when the recipient already has access to the repository, because access control, retention and audit come for free.
GitHub Actions
actions/upload-artifact stores files against the workflow run. GitHub keeps artifacts and logs for 90 days by default; the retention-days input can shorten that per artifact, and repository or organization settings allow 1 to 90 days for public repositories and up to 400 days for private ones. Since version 4, artifacts are immutable. The action returns an artifact-url output, but that URL only works for users who are signed in to GitHub, and downloading requires read access to the repository. That is good for teammates and awkward for a customer or a tester without an account.
- uses: actions/upload-artifact@v7
id: upload
with:
name: app-build
path: dist/
retention-days: 7
- run: echo "Artifact: ${{ steps.upload.outputs.artifact-url }}"GitLab CI/CD
Jobs declare artifacts:paths and optionally artifacts:expire_in. Without it, the expiry comes from the instance-wide setting, which defaults to 30 days. By default GitLab also keeps the artifacts of the latest pipeline on each branch or tag regardless of their expiry, which surprises people who expect sensitive artifacts to disappear. artifacts:access controls who may download (all, developer, maintainer or none); with the default, the artifacts of a public project's pipelines can be downloaded by anyone, including anonymous users.
build:
script: make dist
artifacts:
paths: [dist/]
expire_in: 1 week
access: developerObject storage, presigned URLs and release assets
When the recipient has no CI account, the common pattern is to upload the artifact to a bucket and send a presigned URL. It works for anyone with a browser, but it puts write credentials for the bucket in the CI environment, leaves a stored copy that a lifecycle rule must delete, and creates a bearer token: AWS describes presigned URLs in exactly those terms, and they can be used any number of times until they expire.
Expiry has a subtlety in CI. With Signature Version 4, a URL signed with long-lived IAM user keys can last up to 7 days, but one signed with temporary credentials expires when those credentials do. Well-run pipelines use short-lived credentials (for example OpenID Connect federation into an IAM role), and an assumed-role session lasts one hour by default, so the “7-day” link you generated may stop working after an hour.
Release assets (GitHub Releases, GitLab release links, a package registry) are for builds you intend to publish: they are durable and, in public projects, public. They are the wrong tool for a one-off debug build or anything containing customer data.
Short-lived links streamed from the job
The third option keeps no copy anywhere. The job runs a client that connects out to a relay, prints a link, and serves the artifact when someone opens it. This suits “give QA this build in the next half hour” or “send the customer this diagnostic bundle once”, where storage would be overkill and access to the CI system is not available.
The price is that the job must stay alive until the download finishes, which costs runner minutes and blocks the pipeline, so bound the wait. With SRIFT, the relevant flags are:
--wait: block until the file has been downloaded. Without it the CLI may return while a background daemon serves the link, and the runner kills that daemon when the job ends.--wait-timeout 30m: give up after 30 minutes with exit code 3, so the step fails visibly. The timeout never interrupts a download that is in progress.--once: the link serves exactly one download, so a leaked link is worthless after the intended recipient uses it.--ttlsets an absolute expiry.--encrypt: the artifact is sealed with AES-256-GCM in the job, and the key travels in the#k=fragment of the link, which browsers do not send to servers. Without it the relay handles the plaintext bytes and real file name, protected only by TLS.
The CLI needs only outbound HTTPS on port 443, and on runners that cannot start a background process it serves from its own process. It is the same mechanism described in moving files out of containers.
A GitHub Actions job that delivers a link privately
The job below builds an archive, records its checksum, starts a single-use encrypted link in the background, masks the link before anything could print it, posts it to a team webhook stored as a repository secret, and then waits up to 30 minutes for the download.
name: share-build
on:
workflow_dispatch:
jobs:
share:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Build
run: |
npm ci
npm run build
tar -czf app-build.tar.gz dist
sha256sum app-build.tar.gz | tee app-build.tar.gz.sha256
- name: Share once, privately
env:
WEBHOOK_URL: ${{ secrets.TEAM_WEBHOOK_URL }}
run: |
# --json prints the link to share.json only, never to the log
npx -y srift-transfer quick-share app-build.tar.gz \
--encrypt --once --json --wait --wait-timeout 30m > share.json &
pid=$!
for i in $(seq 1 120); do [ -s share.json ] && break; sleep 1; done
url=$(node -p "require('./share.json').downloadUrl || ''")
if [ -z "$url" ]; then echo "quick-share failed"; kill "$pid" 2>/dev/null; exit 1; fi
echo "::add-mask::$url"
sum=$(cut -d' ' -f1 app-build.tar.gz.sha256)
printf '{"text":"Build %s (sha256 %s): %s"}' "${GITHUB_SHA::7}" "$sum" "$url" \
| curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK_URL"
wait "$pid" # exit code 3: nobody downloaded within 30 minutesWhy it is written this way:
- With
--json, the CLI writes one JSON line to stdout and suppresses its human-readable output, so redirecting stdout keeps the link (and its key) out of the log. The::add-mask::command is a second line of defence for anything that prints it later; GitHub requires registering a mask before the value appears in the log. - The link goes to a webhook kept in repository secrets, not to the job summary: summaries are shown on the run page to everyone who can see the run.
waitreturns the CLI's exit status, so a timeout fails the step, andtimeout-minutescaps the cost if something hangs.- Secrets are not passed to workflows triggered by pull requests from forks, which is one reason this example runs on
workflow_dispatch.
Secrets hygiene and provenance
Keeping the link secret
- Deliver links through a protected channel: a chat webhook or email step whose credentials live in CI secrets, or a private issue. Never echo them in a public log.
- For a second factor,
--password(or theSRIFT_LINK_PASSWORDenvironment variable, which can come from a secret) mixes a password into the key; send the password by a different channel than the link. - On self-hosted runners, the CLI records created links, keys included, in
~/.srift/history.jsonl. SetSRIFT_NO_HISTORY=1in the job environment so nothing persists on the runner between jobs. - Remember what remains visible: the relay sees the job's and the recipient's IP addresses, the timing and the size of the transfer, and without
--encryptalso the content. The end-to-end encryption vs TLS article explains the difference.
Proving what was built
Encryption protects the artifact in transit; it does not tell the recipient that the file came from the build they think it did. Publish a SHA-256 checksum from the job (as above) through a channel the recipient trusts, and have them run sha256sum -c app-build.tar.gz.sha256. For releases, sign the artifact: Sigstore's cosign sign-blob can sign files with short-lived certificates tied to the CI workload's identity and records the signature in a public transparency log. See the Sigstore documentation for the signing and verification commands.
Choosing a delivery method
| Method | Recipient needs | Stored copy | Expiry | Main risk |
|---|---|---|---|---|
| GitHub Actions artifact | GitHub account with read access | Yes | 90 days default, 1 day minimum | Over-broad access in public repositories |
| GitLab job artifact | Project access (per artifacts:access) | Yes | expire_in, else instance default; latest kept | Public pipelines' artifacts are public by default |
| Presigned bucket URL | A browser | Yes, until a lifecycle rule deletes it | Up to 7 days, or the signing credentials' lifetime | Bucket credentials in CI; reusable bearer link |
| Release asset | A browser (public projects) | Yes, permanent | Until deleted | Publishing what should have stayed private |
| Streamed link from the job | A browser or srift get | No | --once / --ttl; dies when the job ends | Link in logs; job time spent waiting |
Use the CI store when the recipient is on the team, release assets for real releases, presigned URLs when the recipient downloads later and you accept a stored copy, and a streamed link for a one-off handover that should leave nothing behind. Agents that run builds face the same choice; see how AI coding agents deliver files, and for servers outside CI, getting files off a remote server.
Frequently asked questions
Can someone without a GitHub account download a GitHub Actions artifact?
Not through the artifact URL: GitHub requires the user to be signed in and to have read access to the repository. To hand a build to someone outside GitHub, use a release asset, a presigned storage URL, or a link served from the job.
What happens to a streamed link when the CI job finishes?
It stops working. The job's process serves the bytes, so once the runner tears the job down there is nothing to download from. That is why the job must wait (--wait) until the recipient has the file.
References
- actions/upload-artifact (GitHub)
- Downloading workflow artifacts (GitHub Docs)
- Workflow commands for GitHub Actions (masking values) (GitHub Docs)
- CI/CD YAML syntax reference: artifacts (GitLab Docs)
- Download and upload objects with presigned URLs (AWS)
- Signing blobs with Cosign (Sigstore)
Use SRIFT from a terminal
One command prints a download link that streams from your machine. Needs only outbound HTTPS; --encrypt keeps the key out of the relay.
npx -y srift-transfer quick-share ./build.tar.gz --encryptRelated resources
- Moving Files Out of Docker Containers and Kubernetes Podsdocker cp, kubectl cp, volumes and outbound links compared for getting files out of containers and pods, including distroless and restricted clusters.How-to
- How AI Coding Agents Deliver Files to DevelopersGetting a build, report or dataset out of an AI coding agent or sandbox: base64 in chat, cloud uploads, and streamed download links compared.How-to
- Get Files Off a Remote Server Without Opening Inbound PortsPull logs, dumps and builds from Linux servers, cloud VMs and headless boxes when SSH is not an option: outbound-only methods and their trade-offs.How-to
- Automating Secure File Transfer: the SRIFT REST API and SDKsWhat makes a file-transfer API secure, and how to send and receive files from code with SRIFT: local REST endpoints, events and Python and Node.js SDKs.How-to
