# Build Artifacts The `artifacts:` directive uploads files from the build VM to sourcehut after the build completes. They appear as download links on the job page. ## Basic usage ```yaml image: alpine/edge packages: - go sources: - https://git.sr.ht/~user/mytool tasks: - build: | cd mytool go build -o /home/build/mytool ./cmd/mytool artifacts: - mytool ``` After this build succeeds, the job page at `https://builds.sr.ht/~user/job/N` shows a download link for `mytool`. ## Path rules — these will trip you up **Paths are interpreted literally.** No shell expansion, no globs, no `~`, no environment variables. - ✅ `mytool` — relative to the build user's home directory (`/home/build` on Linux; `/root` on most BSD images — set by the image's `Homedir`). - ✅ `myproject/dist/release.tar.gz` — nested relative path. - ✅ `/home/build/output/binary` — absolute path. - ❌ `*.tar.gz` — glob, doesn't match anything literally. - ❌ `~/dist/binary` — `~` isn't expanded (the worker explicitly checks for `~` and prints "You probably need to remove ~/ from the artifact path." when an artifact fails to read). - ❌ `$HOME/output` — no env expansion. - ❌ `dist/` — directories aren't supported, only single files. - ❌ Two entries with the same basename, e.g. `bin/foo` and `lib/foo` — the manifest parser rejects this with "Expected artifacts to be a list of unique file paths" because the artifact's display name is the basename and would collide. If your build produces files with unpredictable names (versioned filenames, hash-suffixed output), rename them in a task to a stable name first, or tar them all together. ## Hard limits - **8 artifacts max per build.** The worker (`worker/tasks.go:UploadArtifacts`) logs "no more than 8 artifacts per build are accepted" and silently drops the rest — the build doesn't fail, you just get fewer downloads than expected. - **1 GiB per file.** Larger files cause the upload step to fail the entire build with "Artifact exceeds maximum file size". Split into chunks if you must ship more. - **No upload on failure.** Artifact extraction runs only after every task returns success. - **Storage is S3-backed** on the instance (`builds.sr.ht::worker/s3-bucket` config). On a self-hosted instance without S3 configured, `artifacts:` will fail with "Build artifacts were requested, but S3 is not configured for this build runner." — check the instance config before relying on it. ## Only uploaded on success If any task fails, no artifacts are uploaded. The VM persists for SSH debugging, so you can pull files manually with `scp` if you really need them from a failed build — but the standard `artifacts:` mechanism is success-only. ## Pruned after 90 days Artifacts on free sourcehut accounts are deleted 90 days after the build. Don't use this as long-term storage. For releases, the right pattern is: 1. Tag the release with `git tag -a vX.Y.Z`. 2. Run a CI build that produces the release artifact. 3. Upload it to the git tag via the git.sr.ht UI or the GraphQL API (`uploadArtifact` mutation), which attaches the file to the tag's "refs" page where it lives indefinitely. The git.sr.ht annotated-tag attachment is the durable equivalent of GitHub Releases. ## Pattern: variable-name file → stable artifact Build produces `myapp-v1.2.3-linux-amd64.tar.gz` but you want the artifact named consistently: ```yaml tasks: - build: | cd myapp make release VERSION=v1.2.3 # ends up at myapp/dist/myapp-v1.2.3-linux-amd64.tar.gz - rename: | cp myapp/dist/myapp-*-linux-amd64.tar.gz /home/build/myapp-linux-amd64.tar.gz artifacts: - myapp-linux-amd64.tar.gz ``` Two-task split because shell expansion only works inside tasks (which are scripts), not inside `artifacts:` (which is just YAML strings). ## Pattern: multiple binaries → single archive Building several binaries and want one downloadable bundle: ```yaml tasks: - build: | cd mytool go build -o /home/build/dist/mytool ./cmd/mytool go build -o /home/build/dist/mytool-cli ./cmd/cli go build -o /home/build/dist/mytoold ./cmd/daemon - package: | cd /home/build/dist tar -czf /home/build/mytool-bundle.tar.gz . artifacts: - mytool-bundle.tar.gz ``` ## Pattern: per-arch fan-out with artifacts Use separate manifests in `.builds/` so each architecture is a separate job with its own artifact set: ```yaml # .builds/amd64.yml image: alpine/edge arch: x86_64 packages: [go] sources: [https://git.sr.ht/~user/mytool] tasks: - build: | cd mytool go build -o /home/build/mytool-amd64 ./cmd/mytool artifacts: - mytool-amd64 ``` ```yaml # .builds/aarch64.yml image: alpine/edge arch: aarch64 packages: [go] sources: [https://git.sr.ht/~user/mytool] tasks: - build: | cd mytool go build -o /home/build/mytool-aarch64 ./cmd/mytool artifacts: - mytool-aarch64 ``` Each job is independent and produces its own downloads. There's no built-in "matrix output" concept — each job's artifacts page is its own; if you want everything in one place, use a trigger to push them to a release tag, S3, or your own server. ## Pattern: artifact + email notification ```yaml image: alpine/edge packages: [make, gcc] sources: [https://git.sr.ht/~user/myproject] tasks: - build: | cd myproject make release cp dist/myproject-*.tar.gz /home/build/release.tar.gz artifacts: - release.tar.gz triggers: - action: email condition: success to: "Me " ``` The email contains a link to the job page, where the artifact is downloadable. Triggers don't see artifact URLs directly, so a webhook trigger isn't more useful here unless it pulls the job details from the GraphQL API. ## What about uploading artifacts via the API? There is no user-callable mutation to upload arbitrary files to a job. The schema does have `createArtifact(jobId, path, contents)` but it's decorated `@worker` and rejects normal user tokens — only the runner that owns the job can call it. So you cannot: - attach artifacts to a *failed* build, - attach artifacts to a build after it ended, - attach a file named differently from a path that existed in the VM. If you need any of those, write to your own storage from inside the build (`scp`/`rsync`/S3 PUT) and treat the link as the artifact instead. For **release artifacts that should live indefinitely**, the right primitive is git.sr.ht's `uploadArtifact(repoId: Int!, revspec: String!, file: Upload!)` (scope `OBJECTS:RW`), which attaches files to a git ref (typically an annotated tag) — these show up on the tag's page and aren't pruned. `hut git artifact upload …` is a wrapper. ## Common failure modes **"No artifacts shown on job page"** - The build failed (check the log). Artifacts are only uploaded on success. - The path doesn't match. `artifacts: [mytool]` expects exactly `/home/build/mytool`. If your build wrote to `/home/build/myrepo/mytool`, the path should be `myrepo/mytool`. - A glob pattern was used. Globs don't expand; the literal string `*.tar.gz` is searched for and not found. **"Artifact name is weird / has the wrong directory prefix"** The artifact filename in the UI is derived from the basename of the path. `artifacts: [dist/myapp]` shows up as `myapp`, not `dist/myapp`. If you want a different display name, rename the file before listing it as an artifact. **"Artifact is empty"** The most common cause: building into a temporary directory that got cleaned up, then listing a path that doesn't exist. Add a `ls -la /home/build/` task before the build to verify file locations during debugging. **"Artifact upload timed out"** Very large artifacts (multi-GB) may time out on upload. Split into smaller files, or upload to your own storage from inside the build and skip `artifacts:`. ## What `artifacts:` is *not* - **Not for passing files between jobs.** Jobs are independent VMs; there's no `actions/upload-artifact` + `actions/download-artifact` pairing. To pass data between jobs, use a trigger of type `job` and include data inline, or upload to external storage. - **Not for releases.** Use annotated git tags with attached files for that. - **Not for build caching.** No download-from-previous-build feature. Implement caching by pushing to a cache repo with a deploy key (see `secrets-and-oauth.md`).