Skip to content

About

Self-hosted GitHub Actions artifacts: a small server and an upload action

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

self-hosted-artifacts

Keep your GitHub Actions artifacts on a disk or in a bucket you own. GitHub Actions that upload, download and merge artifacts, against a small file server, which keeps them on its disk or in S3 or anything S3-compatible, and which can be a Cloudflare Worker, or against the bucket itself, with no server at all.

GitHub's artifacts cap private storage at 500 MB on the free plan, which a few app builds fill, and actions/upload-artifact can't target your own storage. These actions take the same inputs by the same rules as actions/upload-artifact, actions/upload-artifact/merge and actions/download-artifact, so moving to them means changing the uses: lines and saying where artifacts go.

A server with a disk A server in front of a bucket A bucket alone
Choose it when You have a machine with room, on a network you trust You want a bucket's storage, with the bucket private and no keys in your jobs You'd rather run nothing, and can give jobs the keys and make the bucket public
You run A process on a machine with a disk A small process with no disk, or a Cloudflare Worker Nothing
Say where with server-url server-url s3-bucket, s3-link-base, a region, and s3-endpoint off AWS
What proves who a job is The workflow's OIDC token, with id-token: write, so no secrets The same The bucket's credentials, in the job's environment
Which repositories Private ones of one owner The same Any job with the credentials
Who can read a link Anyone who has it and can reach the server Anyone who has it and can reach the server and the bucket Anyone who has it, from anywhere
Where the bytes go Through the server Straight to and from the bucket Straight to and from the bucket
Expired files Swept hourly Links stop at once, and a lifecycle rule you add removes them Removed at the next upload, and by a lifecycle rule you add
  • No dependencies. The server installs from npm as one package, and the actions run as they are.
  • No account to download. Each upload also answers with an unguessable link that works with curl -L or a browser, and expires by itself.

It's a young project, below 1.0: see Versions and Not supported.

Run the server

Needs Node 26, and an existing directory to store files in, or a bucket. Write a config file:

{
  "store": { "kind": "disk", "dir": "/srv/artifacts" },
  "oidcAudience": "self-hosted-artifacts",
  "oidcRepositoryOwnerId": 123456,
  "linkBases": { "home": "http://192.0.2.10:8420" }
}

oidcRepositoryOwnerId is the user or organisation whose repositories may use the server. Find it with gh api users/<owner> --jq .id, which works for an organisation too.

Then start the server:

npx self-hosted-artifacts serve --config ./config.json

For a long-running service, install a pinned version rather than downloading at each start: npm install self-hosted-artifacts@<version>.

Setting Default Meaning
store required Where files live: see below.
oidcAudience required The token audience to accept. The action's audience must match.
oidcRepositoryOwnerId required The numeric ID of the user or organisation whose repositories may use the server.
linkBases required Object of label to base URL, http or https, with no query or fragment. Each upload answers with one link per label.
host "0.0.0.0" Address to listen on.
port 8420 Port to listen on.
defaultRetentionDays 90 Days to keep a file when the upload doesn't say.
maxRetentionDays 90 The cap. A larger request is clamped, and the real expiry comes back.
maxFileGiB 2 The largest file accepted.

A missing, malformed or unknown setting stops the server at startup with a message naming it. Nothing in the file is secret.

store says which kind of store, and what that kind needs. For "kind": "disk":

Setting Default Meaning
dir required Where files live, as an absolute path. Must already exist: see below.
minFreeGiB 20 Refuse an upload that would leave less free space than this.

The server never creates dir, so files can't land on the wrong disk while a volume is unmounted. On macOS, a directory at a volume's mount path would also send the real disk elsewhere when it mounts. While the store is missing, uploads, listing and deleting get 503, links 404 and /healthz 503, and everything recovers by itself when it returns, with no restart. The server only creates its own tmp/, artifacts/, runs/ and repositories/ inside it.

Run one server per store, on a disk with hard links (APFS, ext4 or XFS, say; not exFAT). A name is a hard link to its artifact's metadata, and uploads and deletes take turns inside the server's process.

Links come only from linkBases, never from Host or X-Forwarded-*, so the links in a response can't be steered by the request.

Logs go to stdout in logfmt. With a disk, expired files and abandoned uploads are swept at startup and hourly.

A server in front of a bucket

The server checks each job's OIDC token and holds the bucket's keys, and the bytes go straight to and from the bucket through URLs with a signature in them. The bucket stays fully private, and no job holds a key. The actions, their inputs and the HTTP API are the same as with a disk, and server-url is still all a job says.

{
  "store": {
    "kind": "s3",
    "bucket": "YOUR-BUCKET",
    "region": "auto",
    "endpoint": "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
  },
  "oidcAudience": "self-hosted-artifacts",
  "oidcRepositoryOwnerId": 123456,
  "linkBases": { "home": "https://files.example.com" }
}
Setting Default Meaning
bucket required The bucket.
region required The bucket's region. On R2, auto.
endpoint AWS The endpoint of anything else that speaks S3, such as R2 or MinIO. Requests then use path-style addressing, and without it virtual-hosted.

The keys are the standard variables, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and, with temporary ones, AWS_SESSION_TOKEN, and never in the file. Without them, the server stops at startup saying so. Temporary keys sign URLs that stop working when they expire, and the server reads them only at startup. Nothing else is checked at startup or per request, so /healthz is always 200 here, and a bucket that fails answers 500, with the reason in the server's log.

Set up the bucket, used for this alone, and with versioning off:

  1. Keep it private. A new bucket is, on AWS and R2, so there's no policy to add and nothing public to turn on. On R2, leave its custom domains and r2.dev URL off.

  2. Add one lifecycle rule across the bucket, at the longest retention you use: maxRetentionDays, 90 by default. This is what removes files, as nothing else does: see below. On AWS,

    aws s3api put-bucket-lifecycle-configuration --bucket YOUR-BUCKET --lifecycle-configuration \
      '{"Rules":[{"ID":"artifacts","Status":"Enabled","Filter":{},"Expiration":{"Days":90},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":7}}]}'

    and on R2, an object lifecycle rule that deletes objects after that many days. The rule's second half removes an upload in parts nobody finished, which R2 does after 7 days by default.

  3. Give the server keys for this bucket alone, that read, write, delete and list its objects. On AWS, s3:GetObject, s3:PutObject, s3:DeleteObject and s3:AbortMultipartUpload on the bucket's objects, and s3:ListBucket on the bucket, without which a missing object reads as 403 and every first upload is a 500. On R2, an API token with Object Read & Write on this bucket.

The store has to honour conditional writes and a checksum, as AWS, R2 and MinIO do: see what's been tried.

A link redirects, with a 302 to a URL on the bucket that works for 5 minutes. So curl needs -L: without it, curl -fO saves an empty file and succeeds. Browsers and the download action follow it by themselves. A HEAD is answered by the server, with no redirect. The bucket's endpoint has to be reachable from wherever a link is used, as well as the server: see the security model.

Expiry is checked on each click. There's no sweep: an expired, deleted or replaced artifact's link answers 404 at once, and its name is free again. An expired file stays in the bucket until the lifecycle rule removes it, as does what a crash or a lost answer leaves. Without the rule, those stay and you pay to store them.

What each costs in requests to the bucket:

Requests R2's classes
A link, GET or HEAD Two: HEAD of the file, GET of its name. A GET's redirect then reads the file Two class B, and a third for the read
Starting an upload One GET One class B
Sending the body One PUT, from the job One class A
Committing HEAD, PUT of the repository's record, GET and PUT of the name, and a DELETE of what it replaced Two class A, two class B
Listing a run One LIST per thousand names, and a GET for each name in each attempt One class A per page, one class B each
Deleting by ID LIST, HEAD, GET, and two DELETEs One class A, two class B

Run it on a Cloudflare Worker

The same server in front of a bucket, with no machine. So everything above holds, from setting up the bucket to what a link does, and a job still says only server-url. The tests run its published bundle in workerd, Cloudflare's runtime, and it has been run for real, on R2, with the actions and a real token.

npm install self-hosted-artifacts

Your whole Worker is one line, in worker.js:

export { default } from 'self-hosted-artifacts/worker';

wrangler.jsonc holds what would be the config file, as SELF_HOSTED_ARTIFACTS_CONFIG, an object or a string of JSON:

{
  "name": "artifacts",
  "main": "worker.js",
  // This date or later: it's what turns on `nodejs_compat`, which the Worker needs.
  "compatibility_date": "2026-08-04",
  "observability": {
    "enabled": true,
    // Cloudflare's own line for each request is noise here: the server logs what matters.
    "logs": { "invocation_logs": false }
  },
  "vars": {
    "SELF_HOSTED_ARTIFACTS_CONFIG": {
      "store": {
        "kind": "s3",
        "bucket": "YOUR-BUCKET",
        "region": "auto",
        "endpoint": "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
      },
      "oidcAudience": "self-hosted-artifacts",
      "oidcRepositoryOwnerId": 123456,
      "linkBases": { "home": "https://artifacts.<YOUR-SUBDOMAIN>.workers.dev" }
    }
  }
}

Set up the bucket as above. Its keys are the Worker's secrets, with AWS_SESSION_TOKEN too for temporary ones. R2's binding can't sign a URL, so on R2 these are still an API token's S3 keys:

npx wrangler secret put AWS_ACCESS_KEY_ID
npx wrangler secret put AWS_SECRET_ACCESS_KEY
npx wrangler deploy

wrangler deploy prints the Worker's URL, which is what linkBases and server-url take. Or use a custom domain, and if you restrict who can reach it, add "workers_dev": false, as the workers.dev URL otherwise stays public.

  • A bucket only. With a "disk" store there's nothing for it to keep files on. There's no sweep to run either, as expiry is checked at each click and the lifecycle rule removes the files.
  • host and port are ignored, so one config means the same to serve and a Worker.
  • Something wrong doesn't stop a deploy, as a Worker can't refuse to start. A bad config or missing keys answer every request with a 500 saying what's wrong, naming settings and variables and never a value, and log the same. So check /healthz once it's deployed, which answers 200 once the config is valid and the keys are there, without trying them.
  • Downloads stop if the Worker is down, as each link goes through it, though the bytes don't.
  • Plan on Workers Paid. The Free plan allows 10 ms of CPU a request, and the smoke run on a newly deployed Worker used 18 ms at the median and 55 ms at the most. Over the limit, a request fails with Error 1102.
  • On the Free plan a request makes at most 50 to the bucket, which a run's list reaches at about 48 names, counting each name once per attempt that holds it, so a re-run reaches it at about half that, and merging lists first. Paid allows thousands.
  • Logs are JSON lines on console, for Workers Logs. They hold what a server's do: see the security model.

Use a bucket

Give each step s3-bucket rather than server-url, and the rest of this README holds: the same inputs, names, attempts, retries and digests. Exactly one of the two, or the step fails. Nothing needs to run, and the server has no part in it. For a bucket that stays private, with no keys in jobs, run a server in front of it.

Input Default Meaning
s3-bucket The bucket. Instead of server-url.
s3-link-base required with s3-bucket What artifact-url starts with: <s3-link-base>/artifacts/<id>/<name>. See your store's setup below.
s3-region AWS_REGION The bucket's region. On R2, auto.
s3-endpoint AWS The endpoint of anything else that speaks S3, such as R2 or MinIO. Requests then use path-style addressing, and without it virtual-hosted.

The credentials are the standard variables, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and, with temporary ones, AWS_SESSION_TOKEN. There is no input for them, and nothing prints them. They need s3:GetObject, s3:PutObject, s3:DeleteObject and s3:AbortMultipartUpload on the bucket's objects, and s3:ListBucket on the bucket, without which a missing object reads as 403 and every first upload fails.

Which run a job belongs to comes from GITHUB_REPOSITORY_ID, GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT. Outside Actions, --scope gives the same.

The store has to honour conditional writes, as AWS, R2 and MinIO do: see what's been tried. Read the security model before you make a bucket public.

On AWS

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write # for aws-actions/configure-aws-credentials, not for these actions
    steps:
      - uses: actions/checkout@v6
      - uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: arn:aws:iam::123456789012:role/artifacts
          aws-region: eu-west-2
      - run: ./build.sh
      - id: upload
        uses: fishballapp/self-hosted-artifacts/upload@v0.9.0
        with:
          s3-bucket: YOUR-BUCKET
          s3-link-base: https://YOUR-BUCKET.s3.eu-west-2.amazonaws.com
          path: dist/
      - run: curl -fLO "${{ steps.upload.outputs.artifact-url }}"

A link is an anonymous read, so this bucket has to allow them, on those keys and nothing else. Use a bucket for this alone, with versioning off, since a deleted version would still be stored.

  1. Turn off the two parts of the bucket's public-access block that refuse a public policy. Leave the two about ACLs on, as nothing here uses one. If your account has a block of its own, that has to allow it too.

    aws s3api put-public-access-block --bucket YOUR-BUCKET --public-access-block-configuration \
      BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=false,RestrictPublicBuckets=false
  2. Add this bucket policy: anonymous GetObject on artifacts/* only. It gives no anonymous listing, and nothing else in the bucket is public.

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "AnonymousLinks",
          "Effect": "Allow",
          "Principal": "*",
          "Action": "s3:GetObject",
          "Resource": "arn:aws:s3:::YOUR-BUCKET/artifacts/*"
        }
      ]
    }
  3. Add one lifecycle rule across the bucket, at the longest retention you use, 90 days at most. This is the backstop: see Expiry. Its second half removes an upload in parts nobody finished.

    aws s3api put-bucket-lifecycle-configuration --bucket YOUR-BUCKET --lifecycle-configuration \
      '{"Rules":[{"ID":"artifacts","Status":"Enabled","Filter":{},"Expiration":{"Days":90},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":7}}]}'

A bucket whose name holds a dot doesn't match S3's TLS certificate when virtual-hosted. Set s3-endpoint: https://s3.<region>.amazonaws.com to use path style.

On R2

      - id: upload
        uses: fishballapp/self-hosted-artifacts/upload@v0.9.0
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
        with:
          s3-bucket: YOUR-BUCKET
          s3-region: auto
          s3-endpoint: https://<ACCOUNT_ID>.r2.cloudflarestorage.com
          s3-link-base: https://files.example.com
          path: dist/

R2 has no bucket policy that could open one prefix: public access is the whole bucket. An artifact's metadata sits in that bucket under a key that can be worked out from a repository's ID, a run's ID and the artifact's name, and it holds the artifact's ID, which is its link. So someone who knows or can guess all of those can read that artifact's link: see the security model. Nothing can be listed.

  1. Make the bucket public. s3-link-base is the URL you choose.
    • A custom domain is recommended for real use. Leave the r2.dev URL off, as it stays public whatever you do with the domain.
    • An r2.dev URL is fine for trying it out or light use. It's rate-limited, and takes no WAF or cache rule, so steps 2 and 3 don't apply: a deleted link stops at once.
  2. With a custom domain, add a cache rule on that host that bypasses the cache. Cloudflare caches .zip, .apk and other file types by default, so a deleted or replaced artifact's link would otherwise keep working for up to two hours.
  3. Optionally, with a custom domain, add a WAF custom rule on the domain's zone with the expression (http.host eq "files.example.com" and not starts_with(http.request.uri.path, "/artifacts/")). It blocks reading the metadata, which is worth it for a bucket holding private repositories' artifacts. For sensitive ones, run a server in front instead.
  4. Add one object lifecycle rule across the bucket that deletes objects after your longest retention, 90 days at most. An upload in parts nobody finished goes by R2's default rule, after 7 days.

Create an R2 API token with Object Read & Write on this bucket for the credentials.

One artifact, in the bucket

artifacts/<id>/<name>                                     the file, with Content-Type: application/octet-stream
                                                          and Content-Disposition: attachment
runs/<repository_id>/<run_id>/<run_attempt>/<sha256(name)>.json
                                                          what makes it exist: its metadata
repositories/<sha256(lower-case owner/name)>.json         a repository's ID, from its name

id is 256 random bits, as a server's download token is. Looking up a name is one key and listing a run is one prefix. The steps read through signed requests, never through the links, so they don't care what's in front of s3-link-base.

Expiry with a bucket

There's no sweeper, so:

  • Every read respects expiresAt in the metadata. An expired artifact isn't listed, downloaded, merged or deleted, and its name is free again.
  • Each upload removes what has expired for its own repository, best-effort, looking at up to 100 names for up to 3 seconds. It can't fail the upload.
  • The lifecycle rule is the backstop, for a repository that stops uploading and for what a crash leaves, an upload in parts nobody finished among it. Without it, those stay and you pay to store them.

Retention defaults to 90 days, and 90 is the most. So an expired link can keep working until the next upload of that repository or that rule removes it, for up to the rule's age. If it mustn't, delete the artifact rather than let it expire.

What's been tried

The tests run against MinIO in a container, as well as a small fake, and have been run against real R2, with the setup above on a custom domain:

  • Races: of 8 clients uploading other content under one name, one succeeded and 7 failed with the usual message. Of 6 uploading the same content, all succeeded with one ID.

  • A damaged body: R2 refused it with BadDigest, and stored nothing.

  • The WAF rule: it blocked runs/, the root and path tricks such as /artifacts/../runs/..., and links still worked. An encoded ..%2f passes the rule, and R2 reads it as a literal key: 404.

  • The cache rule: without it, a deleted .zip link kept answering 200 from the cache. With it, 404.

  • A server in front, on a Worker: the smoke workflow passed against a real Worker and private R2 bucket with a real GitHub token: upload, a taken name refused, download, merge, list, delete, a link's 302, a HEAD's content-length, the digest checked through the redirect. It was installed from the package as this README says. Anonymous reads at the bucket answer 400.

  • A server on Node in front of real R2: the suites pass, other than those that can't there, which step between the server and the bucket.

  • The lifecycle rule: R2 reports it on an object under it, as x-amz-expiration, at its last-modified plus the rule's 90 days. Removing the object is R2's to do, and hasn't been waited for.

  • Uploads in parts: 1.5 GiB came back byte for byte from a bucket alone, in 47 to 52 seconds up from a home connection, where one PUT of it took 64 to 92. Through a real Worker, with a real GitHub token, 1.1 GiB went up in 27 seconds and came back whole. R2 refuses a part with the wrong MD5, a changed signed header or the wrong number, and a part too small, and answers a second finish NoSuchUpload, as the tests expect. Workers Logs hold no upload ID. Unequal parts weren't sent to it: that R2 refuses them is from its docs.

  • On r2.dev: a link answers 200 with nothing cached, and 404 straight after a delete. Metadata is readable there, as accepted.

  • Signed URLs on R2: a PUT with the wrong length, a missing or changed signed header, or the wrong checksum is refused, as is an expired URL.

The Worker ran on a paid account, so its CPU was measured and not refused. AWS is still from its docs, not from a run.

Upload

jobs:
  build:
    runs-on: self-hosted
    permissions:
      contents: read
      id-token: write # to prove to the server which repository this is
    steps:
      - uses: actions/checkout@v6
      - run: ./build.sh
      - id: upload
        uses: fishballapp/self-hosted-artifacts/upload@v0.9.0
        with:
          server-url: http://192.0.2.10:8420
          path: dist/
      - run: curl -fLO "${{ steps.upload.outputs.artifact-url }}"

That uploads what's in dist/ as one zip, artifact.zip. To upload one file as it is, so that its link ends in its own name, set archive: false:

      - uses: fishballapp/self-hosted-artifacts/upload@v0.9.0
        with:
          server-url: http://192.0.2.10:8420
          path: dist/app.apk
          archive: false
          retention-days: 3

Uploading a name again works as it does with actions/upload-artifact: see One name, one artifact.

Input Default Meaning
server-url The server's base URL, as the runner reaches it. Redirects are not followed. Give this or s3-bucket and what goes with it.
path required What to upload: see Choosing the files.
name artifact The name, stored as <name>.zip. It must pass the name rule with .zip added, or the step fails saying so. With archive: false it is ignored, and the file's own name is used.
overwrite false Whether to replace an artifact this run attempt already holds under that name, rather than fail.
if-no-files-found warn With nothing to upload: warn writes a warning and the step succeeds, error fails the step, ignore only says so in the log. The step's outputs stay empty unless it uploaded.
retention-days 0 Days to keep the file. 0 means the default, which is the server's, or 90 with a bucket. A larger number is capped, and expires-at says what really happened.
compression-level 6 Zlib's level for the zip, from 0 (none) to 9 (best). For a large file that doesn't compress, 0 is much faster.
include-hidden-files false Whether to include files and folders whose names start with a ..
archive true Zip everything into one file. false uploads one file as it is, and more than one fails the step.
audience self-hosted-artifacts With server-url, the OIDC audience to request.
Output Meaning
artifact-id The artifact's ID, which is also the token in its links.
artifact-url The first link.
artifact-digest The SHA-256 of what was uploaded, in hex. For a zip, that's the zip.
links Every link, by label, as one-line JSON. With a bucket, the one label is s3.
expires-at When the file expires, as an ISO instant.

Nothing is held in memory: a zip is written to a temp file in RUNNER_TEMP, which needs room for it, and removed however the step ends. A network error or a 5xx is retried a few times with a short backoff and a fresh token or signature, since a failure here would otherwise throw away a whole build; any other refusal fails the step at once with the target's message. A retry that finds its first try was stored after all succeeds with that artifact.

One name, one artifact

Within a run attempt, a name refers to one artifact, as on GitHub. The repository goes by its ID, so renaming it changes nothing. With a server, the repository, run and attempt come from the verified OIDC token, never from the request. With a bucket, they come from the job's variables, which nothing verifies: the credentials are the trust boundary.

  • A re-run starts afresh. Attempt 2 can upload a name attempt 1 used. Both stay, each with its own link, until they expire.
  • Other content under a name that's taken fails the step, with An artifact with this name already exists on the workflow run, and the first artifact stays as it was.
  • overwrite: true replaces it. It gets a new ID and link, and the old link stops working as soon as the new one is stored. An upload that fails leaves the old artifact in place. Only that attempt's artifact is replaced.
  • The same content succeeds, with or without overwrite, answering with the artifact already there, its ID, link and expiry unchanged, so a lost answer can't fail a retry.
  • Two jobs racing for a name: one wins and the other fails as above. With overwrite, both succeed and the last one to finish is what's left.

Choosing the files

path follows the rules of actions/upload-artifact, which come from @actions/glob:

path: dist/app.apk        # a file
path: dist/               # a folder, with what's inside it
path: dist/**/*.apk       # a wildcard: *, ?, [...] and **
path: |                   # several lines add up, and the last line matching a file decides
  dist/
  logs/*.txt
  !dist/**/*.map
  # a comment, ignored
  • Paths are absolute, relative to the working directory, or start with ~/. . and .. can't appear inside one, though ./ can start it. Brace and extended globs ({a,b}, +(a)) aren't wildcards here.
  • What's left off the front of the paths in a zip depends on the patterns, and ! lines don't count. With one pattern, it's the path before the first wildcard, so dist/**/*.apk keeps what follows dist/. A lone file sits at the top. With several, it's their nearest common ancestor, so build/a and build/b/c.txt give a/... and b/c.txt.
  • Hidden files are any starting with a ., or inside a folder starting with one. They are left out unless include-hidden-files is true, even when named: path: .env finds nothing. A path that starts inside a hidden folder, like .cache/out/, is searched as usual.
  • Symlinks are followed, and the zip holds what they point to. A broken one is left out.
  • A file's permissions and modified time go into the zip, and unzip restores them.
  • A path in a zip can't hold ", :, <, >, |, *, ? or a line break, since NTFS can't unpack such a name. One fails the step.

Compared with actions/upload-artifact

File handling and retention match v7. What's left differs because the files go to your server or bucket:

actions/upload-artifact This action
Inputs
path, if-no-files-found, include-hidden-files, compression-level The same names, defaults and rules
name The artifact's name, default artifact, without ", :, <, >, |, *, ?, \, / The same default and rule, stored as <name>.zip. Also refused: any control character, ##[, . and .. with or without .zip, and more than 255 bytes
retention-days 1 to 90, default 90; 0 means the default The same by default; a server sets its own default and cap
archive Zips by default; false uploads one file as it is, under its own name The same, and that file's name must pass the rule above too
overwrite Default false. Deletes an artifact of the same name, then uploads The same default, but the old artifact goes only once the new one is stored, and an earlier attempt's is left alone
server-url, audience or s3-bucket, s3-region, s3-endpoint, s3-link-base Not applicable Where to upload
Outputs
artifact-url Needs a GitHub login Needs only the link, and whoever has it can download
artifact-id A number, for GitHub's API A string, the same token as in the link
artifact-digest SHA-256 in hex The same, checked as the file is stored
links, expires-at No Every link by label, and when the file expires
Behaviour
Where files go GitHub, against your plan's storage quota A disk or a bucket you own, behind a server, 2 GiB per upload by default, or a bucket alone, 5 TiB per upload
Which repositories Any With a server, private ones of one owner, as public repositories' artifacts are free on GitHub. With a bucket, any job with the credentials
Runners Any Linux and macOS. path isn't read the Windows way
Job permission None to add With a server, id-token: write. With a bucket, credentials in the environment
Names One artifact per name in a run attempt The same, and names differing only in case are two artifacts
The same name and content again Fails, unless overwrite Succeeds with the artifact already there, so a retry is safe
Zipping Streams to GitHub Writes a temp file first, so RUNNER_TEMP needs room for the zip
Getting a file actions/download-artifact, the run's page, the API The download action, or curl or a browser with the link the step prints
Deleting early The run's page or the API delete on the command line, for that repository

Download

jobs:
  deploy:
    needs: build
    runs-on: self-hosted
    permissions:
      id-token: write # to prove to the server which repository this is
    steps:
      - id: download
        uses: fishballapp/self-hosted-artifacts/download@v0.9.0
        with:
          server-url: http://192.0.2.10:8420
          name: build
          path: out
      - run: ls -R "${{ steps.download.outputs.download-path }}"
Input Default Meaning
server-url The server's base URL, as the runner reaches it. Files come from here, not from a link's base, which may be one only people can reach. A file's redirect is followed, as a server in front of a bucket answers with one, and no other is. Give this or s3-bucket and what goes with it.
name every artifact The artifact to download. build finds the zip build.zip; failing that, a file of exactly that name, such as app.apk, which is how one uploaded with archive: false is named. Fails if there is neither.
artifact-ids The IDs to download, comma-separated, from an upload's artifact-id output. Can't go with name. Fails if none is found, and warns about any that isn't.
path $GITHUB_WORKSPACE Where they go. It can start with ~, and is made if it isn't there.
pattern A glob the artifacts' names must match. Ignored with name or artifact-ids. Matching nothing isn't a failure.
merge-multiple false Whether several artifacts share path, rather than taking a folder each. They unpack one at a time in name order, so the last wins a path they both hold.
github-token Accepted and ignored, with a note saying so, so a workflow moving over needn't change.
repository the job's Which repository to read, as owner/name, in any case.
run-id the job's Which run to read.
skip-decompress false Whether to keep a zip as it is, rather than unpacking it.
digest-mismatch error What to do when a file's SHA-256 isn't what was stored with it: ignore, info, warn or error. With error, that artifact isn't unpacked, the rest still download, and the step fails naming each one that didn't match.
audience self-hosted-artifacts With server-url, the OIDC audience to request.
Output Meaning
download-path The absolute path it all went to.
path/                       one artifact, by name, by ID or as the only match of a pattern
  ...its files
path/build/...              several artifacts: a folder each, named as they were uploaded
path/other/...
path/...all of them         several, with merge-multiple
path/app.apk                a file uploaded with archive: false; among several, path/app.apk/app.apk
path/build.zip              with skip-decompress; among several, path/build/build.zip
  • repository and run-id work alone or together. Each takes the job's own when left out, so run-id alone reads another run of this repository, and repository alone looks in this run's number over there, which finds nothing. With a server, reading works across the private repositories of its owner, and with a bucket across every repository in it. A repository can be found by name once it has uploaded with this version, in a job rather than with --scope.
  • Across attempts, the latest wins. For each name in a run, a read takes the latest attempt that holds it, as upstream's list with latest does. So an earlier attempt's ID isn't found once a later attempt holds its name, and a name only an earlier attempt holds is still found.
  • A file name is what's stored. What ends in .zip is unpacked; anything else is written as that file, with 644. A folder's name and what pattern matches leave off the .zip.
  • Nothing is held in memory. Each file streams to a temp file in RUNNER_TEMP, which needs room for it. Its size and SHA-256 are checked there, and only then is it unpacked or moved, so a mismatch with error leaves nothing behind. The temp file goes however the step ends. A network error, a 5xx or a body cut short is retried a few times; any other refusal fails the step at once.
  • A zip can't write outside its folder. Every entry is checked before the first is written, and a path that leads out, an absolute path or any symlink fails the step with nothing written. The same goes for a symlink out that the destination already holds, in a folder an entry goes through. Where a file goes, one already there is replaced, symlink or not, and never followed. If a zip you trust holds symlinks, set skip-decompress and unpack it yourself.
  • A zip bomb can't fill the disk. What the entries declare has to fit in the free space, whatever their ratio, and an entry that gives more than it declared fails the step.
  • Permissions in a zip are restored, without the setuid, setgid and sticky bits. Modified times aren't.

Compared with actions/download-artifact

Inputs, layout and messages match v8. What's left differs because the files come from your server or bucket:

actions/download-artifact This action
Inputs
name, path, pattern, merge-multiple, skip-decompress, digest-mismatch The same names, defaults and rules
artifact-ids Numbers, for GitHub's API The strings an upload's artifact-id outputs
github-token What lets it read another run or repository Accepted and ignored: the job's OIDC token, or the bucket's credentials, is enough
repository, run-id Ignored without github-token Read with or without it
server-url, audience or s3-bucket, s3-region, s3-endpoint, s3-link-base Not applicable Where to read from
Behaviour
Which repositories Any the token can read With a server, private ones of one owner, without a token to manage. With a bucket, any in it
Runners Any Linux and macOS
Job permission None to add for this run; actions: read on a token elsewhere With a server, id-token: write. With a bucket, credentials in the environment
Unpacking Streams, so a bad digest is only known after unpacking A temp file first, so RUNNER_TEMP needs room, and a bad digest with error unpacks nothing
merge-multiple with a path two artifacts hold Whichever finished last, as five download at once The last in name order, every time
Permissions in a zip Lost: 644 and 755 Restored, without the setuid, setgid and sticky bits
Symlinks in a zip Written as a file holding the target Refused, with nothing written
A zip that declares more than the disk has Fails when the disk fills Fails before writing anything

Merge

Combines the artifacts of a run into one, as actions/upload-artifact/merge does. It downloads the matching artifacts, uploads what they hold as one, and can delete them once that is stored.

jobs:
  merge:
    needs: build
    runs-on: self-hosted
    permissions:
      id-token: write # to prove to the server which repository this is
    steps:
      - id: merge
        uses: fishballapp/self-hosted-artifacts/upload/merge@v0.9.0
        with:
          server-url: http://192.0.2.10:8420
          name: all-builds
          pattern: build-*
          delete-merged: true
      - run: curl -fLO "${{ steps.merge.outputs.artifact-url }}"

With no inputs but where artifacts are, every artifact of the run becomes one called merged-artifacts, which is stored as merged-artifacts.zip.

Input Default Meaning
server-url The server's base URL, as the runner reaches it. A file's redirect is followed, as a server in front of a bucket answers with one, and no other is. Give this or s3-bucket and what goes with it.
name merged-artifacts The name of the merged artifact, stored as <name>.zip. It must pass the name rule with .zip added.
pattern * A glob the artifacts' names must match, without their .zip, as in the download action.
separate-directories false Whether each artifact goes in a folder of its name in the merged zip, rather than all in the root.
retention-days 0 Days to keep the merged artifact. 0 means the default, as for an upload. A larger number is capped.
compression-level 6 Zlib's level for the merged zip, from 0 (none) to 9 (best).
delete-merged false Whether to delete the merged artifacts once the merged one is stored.
include-hidden-files false Whether the merged zip holds files and folders whose names start with a ..
audience self-hosted-artifacts With server-url, the OIDC audience to request.
Output Meaning
artifact-id The merged artifact's ID, which is also the token in its links.
artifact-url Its first link.
artifact-digest The SHA-256 of its zip, in hex.
links Every link, by label, as one-line JSON.
expires-at When it expires, as an ISO instant.
  • It reads each name's latest attempt, as actions/download-artifact lists it. Nothing matching the pattern fails the step, with No artifacts found matching pattern '<pattern>'.
  • The merged artifact is never one of its own inputs. A re-run's * would otherwise take in the attempt before's <name>.zip, and delete-merged would delete it. Another artifact of any other name that the pattern matches is merged as usual.
  • Layout: with separate-directories: false, they unpack one at a time in name order into one folder, so the last wins a path they both hold. With true, each goes in a folder of its name, even when only one matches. A file uploaded with archive: false is a file app.apk in the root, or in app.apk/app.apk. Then include-hidden-files decides what the new zip takes, so with false, a hidden file in an artifact is left out of the merged one. So is an artifact whose name starts with a ., which then stays where it is, whatever delete-merged says.
  • Each artifact's digest is checked as the download action's digest-mismatch: error does. A mismatch fails the step with nothing uploaded and nothing deleted.
  • Originals go last. A failure at any step before the merged artifact is stored, a refused upload included, leaves every one in place. Then each is deleted, five at a time. If any delete fails, the step fails naming each one, though the merged artifact stays and the outputs are already set. Deleting is held to the job's own repository, as the delete command is.
  • Merging again is a name in a run attempt like any other. A re-run holds <name>.zip afresh, and leaves the attempt before's alone. Within one attempt, the same files merge to the same zip, since every file in it is stamped 1980-01-01, so a step that runs twice, or is retried after a delete failed, succeeds with the artifact already there and goes on to delete. Other content under the name fails the step as an upload would. If a delete failed part-way, the rest are fewer, so merging again fails this way: delete what's left with delete.
  • Nothing is held in memory, but the runner's temp folder holds every artifact unpacked, then the merged zip, and removes them however the step ends. RUNNER_TEMP needs room for both. The merged zip counts as one file towards the limits.

Compared with actions/upload-artifact/merge

Inputs, outputs, defaults and messages match v7. What's left differs because the files go to your server or bucket:

actions/upload-artifact/merge This action
Inputs
name, pattern, separate-directories, retention-days, compression-level, delete-merged, include-hidden-files The same names and defaults
name Without ", :, <, >, |, *, ?, \, / The same, stored as <name>.zip, with what Upload also refuses
server-url, audience or s3-bucket, s3-region, s3-endpoint, s3-link-base Not applicable Where to read and write
Outputs
artifact-id, artifact-url, artifact-digest A number, a URL that needs a GitHub login, SHA-256 in hex A string, a link anyone can use, and the same digest
links, expires-at No Every link by label, and when it expires
Behaviour
Which artifacts The pattern alone The pattern, less the merged artifact's own name
Downloads Five at once, so a path two artifacts hold goes to whichever finished last One at a time, in name order
Digest of a download Not checked Checked, and a mismatch fails the step with nothing uploaded
Deleting By name, all at once, after the upload By ID, five at a time, after the upload, and each is tried
Where it unpacks A folder in the working directory A folder in RUNNER_TEMP, removed however the step ends
Merging again in a run attempt Fails The same files succeed; other content fails
A bad input Fails once the rest has run Fails before anything is downloaded
Which repositories Any With a server, private ones of one owner. With a bucket, any job with the credentials
Runners Any Linux and macOS
Job permission None to add With a server, id-token: write. With a bucket, credentials in the environment

From the command line

The actions wrap commands that take the same inputs as flags, values included, and print the result as JSON. A name starting with - needs its flag written --name=-x, as --name -x reads as a flag. A server only accepts a GitHub OIDC token, so against one these work only inside a workflow's job, with id-token: write. Against a bucket they work anywhere with the credentials: outside Actions, --scope <repository ID>/<run ID>/<run attempt>, each a number, says which run this is, and wins over the job's variables. Anything else they have to say goes to stderr.

# As the upload action. Repeat --path for several lines.
npx self-hosted-artifacts upload \
  --server-url http://192.0.2.10:8420 \
  --path dist/ --path '!dist/**/*.map' \
  --name build

# As the download action.
npx self-hosted-artifacts download --server-url http://192.0.2.10:8420 --name build --path out

# Each name's latest in a run, narrowed by --name, --artifact-ids or --pattern, from this run unless
# --repository or --run-id say otherwise.
npx self-hosted-artifacts list --server-url http://192.0.2.10:8420 --pattern 'build-*'

# One artifact of this job's repository: by name in this run, or by ID in any run.
npx self-hosted-artifacts delete --server-url http://192.0.2.10:8420 --name build
npx self-hosted-artifacts delete --server-url http://192.0.2.10:8420 --artifact-id "$ID"

# As the merge action. It prints the merged artifact, and what went into it, before it deletes.
npx self-hosted-artifacts merge \
  --server-url http://192.0.2.10:8420 \
  --pattern 'build-*' --name all-builds --delete-merged true

# Any of them against a bucket, from your own machine.
AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... npx self-hosted-artifacts list \
  --s3-bucket YOUR-BUCKET --s3-region eu-west-2 \
  --s3-link-base https://YOUR-BUCKET.s3.eu-west-2.amazonaws.com \
  --scope 123456/987654/1

With nothing to upload, upload writes the message to stderr and prints nothing to stdout, and exits 0 unless --if-no-files-found is error. There is no delete action, as upstream has none.

An upload with --scope knows a repository only by its ID, so it records no name for it, and a --repository owner/name finds it only once a job of that repository has uploaded.

Versions

The server and the action are released together under one version, which follows semantic versioning. Pin the action to a version tag, as above.

Use the same minor version of both, so action v0.6.x with server 0.6.x. A new minor version may change what they expect of each other. Nothing about a bucket involves the server, so a bucket has only the action's version to pin, and what it holds is read by every later action of the same minor version.

HTTP API

This is the server's. A bucket alone has its own layout: see One artifact, in the bucket, which a server in front of a bucket also keeps.

It's the same for either store, but for where a body goes and what a link answers. Every route but a file's and /healthz takes Authorization: Bearer <OIDC token>, checked first: 401 if it's rejected, whose reason is in the server's log, and 503 if GitHub's signing keys can't be fetched. Failures answer { "error": "<message>" }. While the store is missing, these routes answer 503 too.

Storing a file

Three requests, whichever store it is: declare the upload, send the body, commit. The first and last take the token. The body goes to the URL the first answers with, and needs none. A file of 1 GiB or more to a bucket sends its body in parts instead.

1. POST /uploads?name=<filename>&digest=<sha-256>&size=<bytes>, with no body.

Parameter Meaning
name Required, percent-encoded. 1 to 255 bytes, without ", :, <, >, |, *, ?, \, /, ##[ or a control character, and not . or .., with or without .zip; nothing is sanitised.
digest Required. The file's SHA-256, in 64 lowercase hex characters.
size Required. The file's size in bytes.
retention-days Days to keep the file. The server caps it.
overwrite true to replace an artifact the run attempt holds under this name.

If the run attempt already holds this name with this content, that's the end of it: 200 with the artifact, as below, unchanged, whatever overwrite says. Otherwise 201, and where to send the body:

{
  "upload": {
    "kind": "single",
    "id": "<token>",
    "url": "/uploads/<token>",
    "headers": { "content-type": "application/octet-stream" }
  }
}
Status Meaning
200 Already stored.
201 Send the body, as kind says.
400 Bad name, retention, digest, size or overwrite.
409 The run attempt already holds this name with other content, and overwrite isn't true.
413 Over maxFileGiB.
507 The upload would cross minFreeGiB. Only with a disk. A PUT can answer this too.

2. PUT <url>, with exactly those headers and the file as the body, and no token. A url that's a path is resolved against the base URL the client reaches the server by, as a runner may not reach any of linkBases, and a link never comes from Host. An absolute one is used as it is.

From a disk, url is a path on the server, however big the file. It takes a body with a Content-Length or with none, counted against size either way, which is how the client sends 4 GiB or more. It answers:

Status Meaning
200 The body is in place. Another PUT replaces it.
400 A Content-Length that isn't the size declared.
404 No such upload: unknown, over an hour old, already committed, or refused for its digest.
422 The body doesn't match digest. Nothing is kept, and the upload is over.
500 The body wasn't stored, as it was longer or shorter than size, or for any other reason.

With a bucket, url is absolute, headers include what's signed, and the PUT goes to the bucket. fetch sets Content-Length from the body, which is signed too. The bucket answers in its own way: 200, or 403 for the wrong length, a changed or missing header, or a URL over an hour old, and 400 for the wrong checksum. Another PUT of the same bytes replaces it.

3. POST /uploads/<id>/commit, with a token minted for it, and the same parameters as the first. The upload's ID becomes the artifact's. An upload in parts also says what it sent, in its body.

{
  "id": "<token>",
  "name": "app.apk",
  "size": 123,
  "digest": "<sha-256>",
  "expiresAt": "2030-01-08T12:00:00Z",
  "links": { "home": "http://192.0.2.10:8420/<token>/app.apk" }
}
Status Meaning
200 Already there, with this content: this upload committed before, or another that got there first. The answer is that artifact.
201 Stored.
400 Bad name, retention, digest, size or overwrite, or an upload in parts without each of its parts in order.
404 Nothing to commit: the body never arrived, the upload was swept, it declared something else (on a bucket, a different name or size), or it belongs to another repository, run or attempt.
409 The name was taken meanwhile, with other content, and overwrite isn't true. The upload is over.

A client that loses any answer can start again from the first request. If the commit landed, that answers 200, as the digest is stored, so a lost answer costs nothing but the retry.

Sending it in parts

A file of 1 GiB or more, to a bucket. The first request answers with no URL:

{ "upload": { "kind": "multipart", "id": "<token>", "uploadId": "<the bucket's>", "partSize": 16777216 } }

partSize is what every part is but the last, which is what's left: 16 MiB, or more if that would take over 10,000 parts. uploadId is a credential, like an upload's URL, so a client keeps it out of its logs, and sends it only in a body, as a request's URL can reach a server's logs. The server remembers nothing, so each request below says it again, with the first's parameters in the query, a token, and Content-Type: application/json.

POST /uploads/<id>/parts signs up to 64 parts, numbered from 1, for an hour, asking nothing of the bucket:

{ "uploadId": "<the bucket's>", "parts": [{ "partNumber": 1, "md5": "<base64>" }] }
{ "parts": [{ "partNumber": 1, "url": "<on the bucket>", "headers": { "content-md5": "<base64>" } }] }

400 for a part the declared size has no need of, a bad MD5, more than 64 parts, or a file under 1 GiB, and 404 from a disk. Each url takes a PUT of that part, with those headers and no token: another PUT of the part replaces it. The bucket answers with an ETag, 403 for the wrong length or a changed or missing header, and 400 for the wrong MD5. Sign a part again if its URL is getting old.

POST /uploads/<id>/commit then takes every part, in order, as the bucket answered:

{ "uploadId": "<the bucket's>", "parts": [{ "partNumber": 1, "etag": "\"<etag>\"" }] }

It makes the object out of them, then answers as above, so it can be sent again with the same body. If the bucket no longer knows the upload, the object decides: 404 unless it's there with this size and this run's scope.

POST /uploads/<id>/abort, with { "uploadId": "<the bucket's>" }, stops an upload that won't finish, best-effort: 200 whether or not the bucket could, and 404 from a disk. What's left goes by the bucket's rule.

Listing a run

GET /artifacts answers with each name's latest artifact in a run, in name order, from the latest attempt that holds it, leaving out what has expired. Any private repository of the owner can read any other's. Optional parameters:

Parameter Meaning
repository owner/name, in any case. Defaults to the token's. A repository that has never uploaded holds nothing.
run-id Defaults to the token's run.
name Repeatable. Only these exact stored names.
id Repeatable, and not with name. Only these IDs, among what the repository and run hold.
{
  "artifacts": [
    {
      "id": "<token>",
      "name": "app.apk",
      "size": 123,
      "digest": "<sha-256>",
      "expiresAt": "2030-01-08T12:00:00Z",
      "links": { "home": "http://192.0.2.10:8420/<token>/app.apk" },
      "repositoryId": "7",
      "runId": "100",
      "runAttempt": "2"
    }
  ]
}

200 even when nothing matches; 400 for a bad repository, run-id or id, or id with name.

Deleting an artifact

DELETE /artifacts/<id> removes an artifact and its name together, and its link stops at once. The ID must belong to the token's own repository, in any of its runs. It answers { "id": "<id>", "name": "<filename>" }, 403 for another repository's, and 404 for one that isn't there, has expired or was replaced.

Everything else

GET or HEAD /<token>/<filename> serves the file, and needs no token. With a bucket, a GET answers 302 to a URL on the bucket that works for 5 minutes, with Cache-Control: no-store, and a HEAD answers 200 with the file's headers. Anything else is 404: /, /<token>, the wrong filename, an expired file, and any query string.

GET /healthz is 200 when the store is there and 503 when it isn't. A bucket isn't asked, so it's 200.

Security model

Each way to store draws the line in a different place. Read the one you use.

With a server

  • Who can upload: a GitHub Actions workflow in a private repository of the configured owner. The token must be signed by GitHub with RS256 and carry the right issuer, audience, owner ID and repository_visibility: private, within its validity. Every request is checked, including from localhost. Any private repository of that owner qualifies; there is no per-repository list.
  • Who can send a body: anyone with an upload's URL, as a PUT takes no token, so the URL is a capability. It holds 256 random bits, and is given only to a job that passed the check above. It works for an hour from when it's made, and until its upload is committed. It's bound to what was declared: the size, the digest and, through them, the bytes, so all it can write is that one file, which still takes a token of the same repository, run and attempt to become an artifact. Sent several times at once, each copy takes room until it ends, and each is checked against minFreeGiB as it starts. Unlike a link, it's a credential, so neither the client nor the server prints it, and the server logs only a short prefix.
  • Who can list: a workflow that passes the same check, for any private repository of that owner. One owner is the trust boundary, and what a list gives is links, which aren't credentials anyway.
  • Who can replace an artifact: only a job of the same repository, run and attempt. Those come from the token's claims, and no request can name another scope.
  • Who can delete an artifact: only a job of the same repository, by the repository_id claim, in any run. Another repository of the owner gets a 403, so a read across repositories can't become a write. A merge deletes only what it lists in its own run, by those rules.
  • Who can download: anyone who has the link and can reach the server. There is no login: a link holds 256 random bits, so it can't be guessed, and it is meant to be shared. What keeps a file private is who can reach the server, so keep it on a network you trust. Plain http is readable by anyone on the path, so use https for a link that crosses anything you don't control. The upload and merge steps print their links to the run's log, and don't mask their outputs.
  • What a file can do: nothing in a browser. Downloads are application/octet-stream, as an attachment, with X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox; default-src 'none'.
  • What a name can do: nothing on disk. Files are stored under the server's own random ID, the name lives in metadata next to them, and the only trace of it in a path is its SHA-256. A repository's name is the same, as repository could otherwise walk the store, and run-id is digits only.
  • What a zip can do to a runner: nothing outside its folder. See Download. The client holds a server's answers to the server's own rules too, so a name or ID that could lead out of the destination fails the step.
  • What gets logged: never the OIDC token or an upload's URL, and only a short prefix of a download token or an upload's ID. Uploads log the repository, run, attempt, name, size and digest. In the run's log, the download action prints each artifact's ID, and the upload and merge actions print its links.

With a server in front of a bucket

What's above holds for who can upload, list, replace and delete, as the server decides those from the verified token just as it does with a disk. What's different is where the bytes go:

  • No job holds a key, and the bucket stays private. Only the server has the keys. Without a URL it signed, the bucket answers nothing to anyone, so metadata can't be read and nothing can be listed.
  • Who can send a body: anyone with an upload's URL, for an hour, as a capability. It holds a signature, and is given only to a job that passed the check. It's bound to that key, the length, the checksum and the run, so all it can write is that one file, which still takes a token of the same repository, run and attempt to become an artifact. Unlike a link, it's a credential, so neither the client nor the server prints it.
  • What holds an upload in parts: a file of 1 GiB or more goes this way, and S3 can't check a whole file's SHA-256 across parts. So what's signed into each part's URL is its key, its upload, its number, its length and its MD5, the numbers are only those the declared size needs, and a commit reads back the object's length and run. An upload's ID is a credential, and with it a job of another run could send parts or finish the upload, though that job's commit still answers 404. The digest in the artifact is then the client's word for it, which the client's own download checks, and a server has nothing to check a link's bytes with.
  • Who can download: anyone who has the link and can reach the server, which then gives them a URL on the bucket for 5 minutes. So what keeps a file private is the link alone, and who can reach the server: once the redirect is followed, that URL works from anywhere, and the bytes never come through your network. A deleted, expired or replaced artifact's link stops at the next click, with its data gone or not. A URL already given out can finish its 5 minutes, unless the data has gone.
  • What a redirect shows: whoever follows a link sees the access key's ID and the bucket's endpoint and name in the URL, and the repository's ID, run and attempt in x-amz-meta-run-scope. A session token is in that URL too, if you use one, so prefer long-lived keys limited to this bucket. The secret stays on the server.
  • What a file can do: it's served as application/octet-stream, as an attachment, by the bucket. S3 can't add the server's X-Content-Type-Options and Content-Security-Policy headers, and a browser that ignored the attachment would meet it on the bucket's origin, which holds nothing else.
  • What the keys can do: everything in the bucket. The server is what holds a job to its own repository, so protect it and its environment as you would the bucket.
  • What gets logged: never a credential, the OIDC token, an upload's ID in the bucket or a URL with a signature in it, and only a short prefix of a download token or an upload's ID here, in a failure's key too. On a Worker, Cloudflare adds each request's URL to its lines, and was seen to show a token in a path, and a digest, as REDACTED. A query's other values, such as name, stay.
  • On a Worker, a misconfigured server says so to anyone, as nothing else can tell whoever deployed it. Its 500 names settings and variables, never a value.

With a bucket

  • Anyone with the bucket's credentials can read, overwrite or delete anything in it. They are the whole trust boundary. The server's checks don't exist here: no private-repository check, no owner check, and nothing verifies which job is asking. A job says which repository, run and attempt it is, through its variables, and could say anything. Every repository sharing a bucket trusts every other, so use a bucket per trust boundary, and keep the credentials to the jobs that need them. What holds a job to deleting only its own repository's artifacts, and to only replacing its own attempt's, is this client, not the bucket.
  • Who can download: anyone with the link, from anywhere on the internet, with no login. A link holds 256 random bits, so it can't be guessed. There is no network to keep it private, as a server has. Making that work means turning off this bucket's public-access block for a policy, so don't keep anything else in this bucket.
  • What's public: on AWS, exactly artifacts/*, by the policy above: anonymous GetObject, no listing, and neither runs/ nor repositories/. A link's answer on AWS shows its repository's ID, run and attempt, in x-amz-meta-run-scope. On R2 the whole bucket is public unless the WAF rule above stops it. Without that rule, someone who knows the bucket's domain, a repository's name, a run's ID and an artifact's name can read that artifact's link, as the repository's ID can be read from repositories/<sha256(owner/repo)>. Nothing can be listed, but run IDs aren't random. That makes guessing a targeted attack of millions of requests against a repository already known, for one artifact, with no rate limit on a custom domain. If that matters to you, add the rule, or for anything sensitive, run a server in front.
  • Who can list: only someone with the credentials. Clients read with signed requests.
  • An expired link: can keep working until the next upload or the lifecycle rule removes it. See Expiry. A deleted or replaced artifact's link stops as its data goes, which on R2 needs the cache rule above.
  • What a file can do: it's served as application/octet-stream, as an attachment. S3 can't add the server's X-Content-Type-Options and Content-Security-Policy headers, and a browser that ignored the attachment would meet it on the bucket's origin, which holds nothing else.
  • What holds an upload in parts: the client, and S3 for each part's MD5 and the object's size. A file of 1 GiB or more is sent in parts, each with its MD5, so no one but the client checks the whole file's SHA-256 as it arrives. A download checks it.
  • What a name can do: the same as with a server. Names reach a key only as their hash, or checked against the name rule in artifacts/<id>/<name>, and whatever a bucket answers is held to those rules before it reaches a path.
  • What gets logged: never a credential. Failures name a key, and S3's code and message, and never the rest of its answer, which can hold the access key's ID.

Limits

  • With a server, 2 GiB per file by default, checked from the size declared and counted while streaming, or with a bucket, signed into the upload's URLs. A zip counts as one file.
  • A file of 1 GiB or more to a bucket goes in parts, whether a server is in front of it or not: 16 MiB each, more only to fit 10,000 parts, and 5 TiB at most, 4 at a time, so what's in memory stays bounded whatever the size. Only a failed part is sent again. Anything smaller goes as one PUT, whose checksum the bucket checks.
  • A disk's server takes one stream, whatever the size, up to maxFileGiB.
  • A file goes through a temp file in RUNNER_TEMP if it's zipped, so the runner needs room for it.
  • 90 days' retention at most by default, and always with a bucket.
  • One hour for a request to a server, enough for 2 GiB at about 5 Mbit/s. A slower upload is cut off. An upload's URL takes a PUT for an hour after it's made, and a part asks for a new one if its own is getting old, so parts have no such limit. A link's URL on a bucket starts a download for 5 minutes.
  • A run's list is answered in one response from a server, with no pages. A bucket's is read in pages, with one read per artifact, by the server if there's one in front of it.
  • Five minutes idle on a server: a client that stops reading or sending is cut off, downloads included. A stalled download is cut off after 10 to 15 minutes, as Node restarts the timer while a write is pending.

Not supported

A web UI, Windows runners, and a list of a run's earlier attempts where a later one holds the name, or, with a bucket, a bucket that also holds other things.

License

MIT

About

Self-hosted GitHub Actions artifacts: a small server and an upload action

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages