Developer & Server Workflows

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.

.github/workflows/build.yml (excerpt)
- 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.

.gitlab-ci.yml (excerpt)
build:
  script: make dist
  artifacts:
    paths: [dist/]
    expire_in: 1 week
    access: developer

Object 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.

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.

.github/workflows/share-build.yml
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 minutes

Why 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.
  • wait returns the CLI's exit status, so a timeout fails the step, and timeout-minutes caps 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 the SRIFT_LINK_PASSWORD environment 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. Set SRIFT_NO_HISTORY=1 in 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 --encrypt also 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

How a CI artifact reaches a person with each method.
MethodRecipient needsStored copyExpiryMain risk
GitHub Actions artifactGitHub account with read accessYes90 days default, 1 day minimumOver-broad access in public repositories
GitLab job artifactProject access (per artifacts:access)Yesexpire_in, else instance default; latest keptPublic pipelines' artifacts are public by default
Presigned bucket URLA browserYes, until a lifecycle rule deletes itUp to 7 days, or the signing credentials' lifetimeBucket credentials in CI; reusable bearer link
Release assetA browser (public projects)Yes, permanentUntil deletedPublishing what should have stayed private
Streamed link from the jobA browser or srift getNo--once / --ttl; dies when the job endsLink 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

  1. actions/upload-artifact (GitHub)
  2. Downloading workflow artifacts (GitHub Docs)
  3. Workflow commands for GitHub Actions (masking values) (GitHub Docs)
  4. CI/CD YAML syntax reference: artifacts (GitLab Docs)
  5. Download and upload objects with presigned URLs (AWS)
  6. 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 --encrypt