A justfile => justfile +12 -0
@@ 0,0 1,12 @@
+_default:
+ @just --list
+
+skill := "sourcehut-ci"
+src := "skills/" + skill
+dst := "~/.claude/skills/" + skill
+
+# Install the backed-up skill into ~/.claude/skills (repo -> ~/.claude)
+install:
+ mkdir -p {{dst}}
+ rsync -a --delete --exclude='.*' {{src}}/ {{dst}}/
+ @echo "Installed {{skill}} -> {{dst}} ($(find {{dst}} -type f | wc -l | tr -d ' ') files)"
A skills/sourcehut-ci/SKILL.md => skills/sourcehut-ci/SKILL.md +352 -0
@@ 0,0 1,352 @@
+---
+name: sourcehut-ci
+description: Author and debug builds.sr.ht CI manifests for SourceHut projects (including the user's self-hosted instance at *.srht.bigb.es), publishing static sites to pages.sr.ht, and producing downloadable build artifacts. Use this skill whenever the user mentions sourcehut, sr.ht, srht.bigb.es, builds.sr.ht, pages.sr.ht, .build.yml, .builds/, hut, a hut pages publish workflow, deploying a static site to srht.site or srht.bigb.es, sourcehut artifacts, OAuth grants in CI manifests, or post-build triggers — even when they don't explicitly say "CI". Also use it when the user is troubleshooting a failed sourcehut build, asks how to skip CI on push, wants to test patches on a mailing list, or wants matrix-style multi-image builds on sourcehut. SourceHut CI works very differently from GitHub Actions, GitLab CI, and Jenkins; this skill encodes those differences so the output works on the first submission rather than after several broken pushes.
+license: BSD-3-Clause
+metadata:
+ audience: developers
+ workflow: ci
+ tags: [ci, sourcehut, sr.ht, builds.sr.ht, pages.sr.ht, yaml]
+ version: 1.1.0
+ author: bigbes
+---
+
+# SourceHut CI (builds.sr.ht)
+
+This skill covers the **builds.sr.ht** continuous integration service: how to write build manifests, deploy artifacts to **pages.sr.ht**, and produce downloadable **build artifacts**. It also covers the surrounding ecosystem (git.sr.ht push integration, secrets, `hut`, mailing list patch testing) to the extent needed to make CI work end-to-end.
+
+The single most important mental model: **SourceHut CI is stateless and explicit**. Every build is described entirely by a YAML manifest submitted to the API. There is no web UI for configuring jobs, no environment-variable settings page that secretly modifies behavior, no implicit context beyond what's in the manifest. This is a feature, not a limitation — but it means habits from GitHub Actions / GitLab CI / Jenkins will mislead.
+
+## When to consult which reference
+
+This SKILL.md is enough for ~80% of tasks. For the rest, read the matching file in `references/`:
+
+- `references/manifest.md` — complete field reference for `.build.yml` (every key, every accepted value, every gotcha). Read when writing a non-trivial manifest from scratch, or when a user asks "does sr.ht support X" for a manifest feature.
+- `references/pages.md` — pages.sr.ht specifics: tarball constraints, custom domains, multi-domain publishing, `hut pages publish` flags, common static site generator recipes. Read whenever the task involves publishing a website.
+- `references/artifacts.md` — how the `artifacts:` directive works, its limitations, and patterns for working around them (notably no globbing). Read when the user wants downloadable build outputs.
+- `references/secrets-and-oauth.md` — how to register secrets, the difference between file and environment secrets, and how the in-manifest `oauth:` directive mints short-lived tokens. Read whenever secrets or OAuth grants are involved.
+- `references/integrations.md` — git.sr.ht auto-submission, push options, mailing list patch testing, multi-image fan-out, job groups, triggers. Read for "how does my repo trigger builds" questions.
+- `references/debugging.md` — interpreting log output, SSH into failed VMs, `shell: true`, `complete-build`, common errors. Read when the user reports a failed build.
+- `references/images.md` — quick selector for which `image:` value to use, package manager differences, architecture support. Read when the user asks "which image should I use" or "is X available".
+- `references/hut.md` — `hut`, the SourceHut CLI, as used inside builds. How the worker provisions `~/.config/hut/config`, every CI-relevant subcommand mapped to the OAuth scope it needs, install recipes per image, common failure modes. Read for any task that runs `hut <something>` in CI, or when the user asks why `hut` can't authenticate.
+
+When more than one reference is relevant, read them all before writing the manifest. They're short by design.
+
+## What goes in `.build.yml` — the minimum
+
+```yaml
+image: alpine/edge
+tasks:
+ - example: |
+ echo "hello world"
+```
+
+That's a complete, valid manifest. `image` is the only required top-level key, and at least one task is required.
+
+A more realistic manifest covers most projects:
+
+```yaml
+image: alpine/edge
+packages:
+ - meson
+ - ninja
+sources:
+ - https://git.sr.ht/~user/myproject
+tasks:
+ - configure: |
+ cd myproject
+ meson build
+ - build: |
+ cd myproject
+ ninja -C build
+ - test: |
+ cd myproject
+ ninja -C build test
+```
+
+Every task runs as a separate bash login session. The preamble injected before each task is:
+
+```bash
+#!/usr/bin/env bash
+. ~/.buildenv
+set -xe
+```
+
+This means: `set -e` is on (any non-zero exit terminates the task and fails the build), `set -x` is on (every command is echoed to the log with variables expanded — be careful with secrets), and `~/.buildenv` is sourced (this is where `environment:` values and any state passed between tasks lives).
+
+Task names must be lowercase alphanumeric, underscore, or dash, ≤128 characters. Tasks run in the order specified. Each task must be a single-key dict (`name: |\n script`); the older `- "just a string"` shorthand mentioned in some legacy docs is rejected by the current parser.
+
+## Where the manifest lives
+
+- **`.build.yml`** at the repo root — git.sr.ht automatically submits this on every push.
+- **`.builds/*.yml`** — multiple manifests, up to 4 are submitted per push (chosen randomly if more exist, because the hook iterates a Go map). This is how sourcehut handles what other CIs call a build matrix.
+- Both `.yaml` and `.yml` extensions are auto-discovered: the default pattern is `.build.yml,.builds/*.yml,.build.yaml,.builds/*.yaml`. Override per-push with `git push -o submit=...`.
+- The 4-build cap is per *push*, not per ref: if you push to several refs with builds, the hook stops submitting once it has produced 4 jobs in total across them.
+- **Submitted ad-hoc** via the web form at `https://builds.sr.ht/submit`, the `hut builds submit` CLI, or the GraphQL API — no repo required.
+
+The simplest workflow when iterating: paste the manifest into the web submit form repeatedly until it works, then commit it as `.build.yml`. Do not commit broken manifests just to test them.
+
+## Publishing to pages.sr.ht
+
+pages.sr.ht hosts static sites. Every user gets `username.srht.site` for free, with automatic TLS. Custom domains work via a single CNAME record.
+
+The CI pattern for publishing is reliable enough to memorize:
+
+```yaml
+image: alpine/edge
+packages:
+ - hut
+oauth: pages.sr.ht/PAGES:RW
+environment:
+ site: username.srht.site
+sources:
+ - https://git.sr.ht/~username/my-website
+tasks:
+ - package: |
+ cd my-website
+ tar -czvf ../site.tar.gz -C public .
+ - publish: |
+ hut pages publish -d "$site" site.tar.gz
+```
+
+Three things make this work:
+
+1. **`oauth: pages.sr.ht/PAGES:RW`** — builds.sr.ht mints a short-lived OAuth 2.0 token with exactly the `PAGES:RW` grant. The worker writes `/home/build/.config/hut/config` with the token as `access-token` (plus per-service origins from the running instance's config), so `hut` Just Works. The same token is also exported as `$OAUTH2_TOKEN` for tools like `curl --oauth2-bearer` — but `hut` itself reads only the config file, not the env var. No secret needed; the token is per-build and dies with the VM. Details: `references/hut.md`.
+2. **The tarball must contain only directories and regular files, mode 644, no symlinks.** Use `tar -C <output-dir> .` to put files at the top level of the tarball (this is what pages.sr.ht expects — no extra wrapping directory).
+3. **`hut pages publish -d <domain>`** — `-d` takes the fully qualified domain. To publish to a custom domain you've configured, pass that domain; to publish to your subdomain, pass `username.srht.site`.
+
+See `references/pages.md` for static site generator recipes (Hugo, Zola, Jekyll, mdBook), custom domain setup, and multi-domain publishing (e.g. apex + www must be published separately).
+
+## Producing downloadable artifacts
+
+Use the `artifacts:` directive when you want to make files downloadable from the job page:
+
+```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
+```
+
+Critical constraints (these will bite you):
+
+- **No globs.** `artifacts: ["*.tar.gz"]` does **not** work. Each path is interpreted literally. If you need a wildcard, build the file under a stable name (rename in a task, or `tar` everything into a single archive).
+- **Paths are interpreted literally** — no `~`, no `$HOME`, no shell expansion. Use absolute paths or paths relative to `/home/build`.
+- **Only successful jobs upload artifacts.** Failed builds discard whatever they produced.
+- **Pruned after 90 days.** Don't use artifacts as long-term storage; for that, push to pages.sr.ht, a git LFS endpoint, or an annotated git tag with attachments.
+- **Single files, no directories.** If you want to ship a directory tree, tar it first.
+
+See `references/artifacts.md` for patterns: matrix-of-binaries, naming with version suffixes, combining with triggers for release automation.
+
+## Secrets
+
+Secrets live on your sourcehut account, identified by UUID (or by the human-readable name, 3–512 chars, you assigned at registration), and you opt them in per-build:
+
+```yaml
+secrets:
+ - 12345678-1234-1234-1234-123456789abc
+ - github-deploy-key # by name also works
+```
+
+Secrets come in three flavors registered at `meta.sr.ht/secrets`: **SSH keys** (mounted at `~/.ssh/id_*` with appropriate permissions), **PGP keys** (imported into the build user's keyring), and **files** (placed at a path you specify with a mode you specify). Plain "environment variable" secrets aren't a separate type — use a file secret containing `KEY=value` and `source` it, or use the OAuth flow described above.
+
+Watch out for `set -x` echoing secret values to the log. If a task references a secret-derived value, run `set +x` before the sensitive command and `set -x` after. See `references/secrets-and-oauth.md` for the full picture, including why `oauth:` is almost always better than `secrets:` for sr.ht-internal services.
+
+## What sourcehut CI does *not* have
+
+Don't suggest these — they don't exist, and trying to fake them with workarounds is usually worse than the obvious alternative:
+
+- **No matrix syntax.** Use multiple files in `.builds/`. Each file is a separate independent job.
+- **No path-based triggers** (e.g. "only run on changes to `docs/`"). Filter inside the script: `if git diff --quiet HEAD HEAD^ -- docs/; then complete-build; fi`.
+- **No caching layer.** Each build starts from a fresh VM. For caching: rsync to a server you control, or use a dedicated cache repo with a deploy key.
+- **No "required for merge" gates** in the GitHub PR sense. SourceHut's contribution model is patch-based via mailing lists; build results post back to the thread as replies.
+- **No globs in `artifacts:`.** Tar your outputs into a single file with a stable name. Hard cap of **8 artifacts per build, ≤1 GiB each**; the worker rejects more.
+- **No `if:` conditions on tasks.** Use `complete-build` (ends the whole build successfully) or in-shell conditionals.
+- **No first-class Docker support.** Builds run inside KVM VMs. Docker can be installed inside those VMs, but the VM is the unit of isolation, not a container.
+- **No environments / deployment protection** in the GitHub Environments sense. Use a job group with a manual-start follow-up job if you need approval gates.
+- **No `irc` or `job` triggers.** Only `email` and `webhook` actions exist in the current worker. Chain deploys with a final task inside the same job, or have a webhook receiver submit a follow-up build.
+
+When the user asks for one of these, name the substitute and write the manifest using it.
+
+## Triggering builds from a push
+
+When the manifest lives in the repo, git.sr.ht handles submission automatically on push. Useful push options:
+
+- `git push -o skip-ci` — don't submit any builds for this push. The push itself still goes through; only the build submission step is skipped server-side. Essential for self-pushing builds (auto-changelog, doc regeneration) to avoid infinite loops.
+- `git push -o submit=".sourcehut/*.yml"` — override the default manifest glob.
+- `git push -o debug` — print the push UUID, useful for support tickets.
+
+The repo you pushed to is automatically rewritten in the manifest's `sources:` to point at the pushed ref, so the build operates on the version you just pushed, not the default branch. `$GIT_REF` is available in the build environment (e.g. `refs/heads/master`).
+
+To restrict which refs trigger builds, use the in-manifest `submitter:` block instead of relying on `complete-build` or push options:
+
+```yaml
+submitter:
+ git.sr.ht:
+ allow-refs:
+ - refs/heads/master
+ - "refs/tags/*"
+```
+
+This filters at submission time — non-matching refs never create a job. The block is interpreted by the **git.sr.ht push hook**, not by builds.sr.ht, so it only affects auto-submission. Manual submits via `hut builds submit`, the web form, or the GraphQL API ignore it entirely. See `references/integrations.md`.
+
+The hook also rewrites the manifest before submission: it injects the cloning URL for the pushed commit into `sources:` (matching by repo basename, appending if no entry matches), sets `BUILD_SUBMITTER=git.sr.ht` and `GIT_REF=<pushed ref>` in `environment:`, and strips `shell: true` if present (with a log notice).
+
+## Post-build triggers
+
+Triggers fire after the build completes. Only **two** actions exist in the current builds.sr.ht code: `email` and `webhook`. Older docs and blog posts mention `irc` and `job` triggers — those do not exist, the worker logs `Unknown trigger action` and ignores them.
+
+```yaml
+triggers:
+ - action: email
+ condition: failure
+ to: "Me <me@example.com>"
+ - action: webhook
+ condition: always
+ url: https://example.com/build-results
+```
+
+`condition` is one of `success`, `failure`, `always`. The webhook POSTs a signed JSON payload describing the job (see `references/triggers.md` for the shape).
+
+For "deploy after tests pass", since there is no `job:` trigger:
+
+- **From outside the build**: have the webhook receiver submit a follow-up build via `hut builds submit` or the GraphQL `submit` mutation.
+- **In-build**: chain the deploy as a final task inside the same manifest, gated on the result of earlier tasks. Use `submitter.allow-refs` so the deploy only runs on branches where it's meant to.
+- **Bundled across jobs**: use the GraphQL `createGroup` mutation to bundle pending jobs into a group with a shared completion trigger; the trigger fires after every member finishes. Group-level triggers in the API accept `EMAIL` and `WEBHOOK`; only `EMAIL` is actually implemented in the worker today (webhook is a `TODO`).
+
+See `references/integrations.md` for the webhook payload, mailing-list patch testing, and `createGroup` details.
+
+## Debugging failed builds
+
+When a build fails, the log prints an SSH command:
+
+```
+ssh -t builds@fra02.builds.sr.ht connect 1234567
+```
+
+You have ~4 hours to SSH in and poke around. The VM is exactly as the build left it. This is the single best feature for debugging — encourage users to use it rather than blindly iterating on the manifest.
+
+For builds that pass but you want to inspect anyway, add `shell: true` to the manifest (keeps the VM alive even on success). Don't leave this in committed manifests — it ties up worker resources.
+
+See `references/debugging.md` for: reading sr.ht log output, what `set -xe` reveals, how to interpret "no such image", debugging OAuth grant errors, why your tarball might be silently rejected by pages.sr.ht.
+
+## Output format expectations
+
+When the user asks to write a manifest:
+
+1. **Lead with the manifest, not commentary.** Put the YAML in a fenced code block as the first or second thing in the response.
+2. **Make the manifest complete and submittable as-is.** No `...` placeholders unless the user has to fill in a value (username, UUID); when you do leave one, mark it `<like-this>` and call out below the manifest what they need to substitute.
+3. **Use the simplest `image:` that works.** Default to `alpine/edge` unless there's a specific reason (e.g. `archlinux` for AUR, `debian/stable` for Debian packaging). Don't reach for `ubuntu/lts` reflexively — Alpine is faster to boot and `apk` packages are usually what people want.
+4. **Always include real OAuth grants and real environment values**, not placeholder text. `oauth: pages.sr.ht/PAGES:RW` is correct; `oauth: <scope here>` is not — it forces the user to look up what the scope should be.
+5. **Don't add features the user didn't ask for.** A request for "deploy my Hugo site" doesn't need triggers, secrets, or artifacts. Keep manifests minimal and let the user add complexity.
+6. **After the manifest, explain non-obvious choices in 1-3 sentences each.** Why `alpine/edge`? Why `oauth:` instead of `secrets:`? Why this particular package name? Don't lecture; just unblock.
+7. **For pages.sr.ht: always verify the tarball's structure.** The `-C <dir> .` pattern is non-negotiable; if the user has the site at `./public/`, the tar must be `tar -czf site.tar.gz -C public .` and not `tar -czf site.tar.gz public/`.
+
+## Examples
+
+**Example 1 — User: "I have a Hugo site I want to deploy to my sourcehut subdomain."**
+
+```yaml
+image: alpine/edge
+packages:
+ - hugo
+ - hut
+oauth: pages.sr.ht/PAGES:RW
+environment:
+ site: username.srht.site
+sources:
+ - https://git.sr.ht/~username/my-site
+tasks:
+ - build: |
+ cd my-site
+ hugo --minify
+ tar -czf ../site.tar.gz -C public .
+ - publish: |
+ hut pages publish -d "$site" site.tar.gz
+```
+
+Substitute `username` (twice) and the repo URL. Commit this as `.build.yml` in the repo root and push.
+
+**Example 2 — User: "I want CI to build my Go program and let me download the binary."**
+
+```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
+ - test: |
+ cd mytool
+ go test ./...
+artifacts:
+ - mytool
+```
+
+The binary lives at `/home/build/mytool` after the build task; `artifacts:` takes the path relative to `/home/build`. The job page will show a download link for `mytool`. Artifacts are pruned after 90 days.
+
+**Example 3 — User: "I want to test on Alpine, Debian, and FreeBSD."**
+
+Create `.builds/alpine.yml`, `.builds/debian.yml`, `.builds/freebsd.yml`:
+
+```yaml
+# .builds/alpine.yml
+image: alpine/edge
+packages:
+ - meson
+ - ninja
+sources:
+ - https://git.sr.ht/~user/myproject
+tasks:
+ - build: |
+ cd myproject
+ meson build && ninja -C build
+ - test: |
+ cd myproject
+ ninja -C build test
+```
+
+```yaml
+# .builds/debian.yml
+image: debian/stable
+packages:
+ - meson
+ - ninja-build
+sources:
+ - https://git.sr.ht/~user/myproject
+tasks:
+ - build: |
+ cd myproject
+ meson build && ninja -C build
+ - test: |
+ cd myproject
+ ninja -C build test
+```
+
+```yaml
+# .builds/freebsd.yml
+image: freebsd/latest
+packages:
+ - meson
+ - ninja
+sources:
+ - https://git.sr.ht/~user/myproject
+tasks:
+ - build: |
+ cd myproject
+ meson build && ninja -C build
+ - test: |
+ cd myproject
+ ninja -C build test
+```
+
+Each is an independent job. Up to 4 of these submit per push. The package name differs (`ninja` on Alpine and FreeBSD, `ninja-build` on Debian) — this is normal and expected; sourcehut doesn't abstract over distros.
A skills/sourcehut-ci/references/artifacts.md => skills/sourcehut-ci/references/artifacts.md +182 -0
@@ 0,0 1,182 @@
+# 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 <me@example.com>"
+```
+
+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`).
A skills/sourcehut-ci/references/debugging.md => skills/sourcehut-ci/references/debugging.md +212 -0
@@ 0,0 1,212 @@
+# Debugging Failed Builds
+
+The single most important debugging tool on sourcehut is SSH into the build VM. Use it; don't iterate blindly on the manifest.
+
+## Reading sr.ht log output
+
+The build log is plain text, tasks separated by headers like:
+
+```
+[#1273143] 2025/01/15 10:23:01 Running task "build"
++ cd myproject
++ make
+gcc -c foo.c
+...
+[#1273143] 2025/01/15 10:23:42 Task "build" failed (exit status 1)
+```
+
+Lines starting with `+` are from `set -x` — they show the command being run, with environment variables expanded. The next lines are the command's stdout/stderr. The trailing line with "failed" gives the exit status.
+
+When a task fails, **everything after that task is skipped**. The summary at the bottom of the log lists task statuses and exit codes.
+
+## SSH into the failed VM
+
+On failure, the log prints:
+
+```
+[#1273143] 2025/01/15 10:23:42 Build failed.
+[#1273143] 2025/01/15 10:23:42 The build environment will be kept alive for 10 minutes.
+[#1273143] 2025/01/15 10:23:42
+[#1273143] 2025/01/15 10:23:42 ssh -t builds@fra02.builds.sr.ht connect 1273143
+[#1273143] 2025/01/15 10:23:42
+[#1273143] 2025/01/15 10:23:42 After logging in, the deadline is increased to your remaining build time.
+```
+
+Run that SSH command. You'll be dropped into the VM as the `build` user, exactly as the build left it. The VM lives for **10 minutes** by default if you don't log in. Once you log in, the deadline extends to your remaining build time (which is `[builds.sr.ht::worker] timeout` minus already-elapsed time, often capped — instance-dependent, log will say "Your VM will be terminated N hours from now").
+
+What to do once inside:
+
+- `cd ~` — `/home/build` is your home, where sources are cloned.
+- Re-run the failing command manually to see actual errors interactively.
+- `which <tool>` — verify a package actually installed and is on PATH.
+- `cat ~/.buildenv` — see exactly what `environment:` set.
+- `env` — full environment, including `$OAUTH2_TOKEN`, `$JOB_ID`, etc.
+- `sudo` is passwordless — install missing packages, modify system config, whatever.
+- `logout` (or Ctrl-D) when done. The VM gets torn down.
+
+For SSH into the VM, your sourcehut SSH key needs to be added at `https://meta.sr.ht/keys`. The same key used for git operations is fine.
+
+## `shell: true` for always-on SSH
+
+Add to the manifest:
+
+```yaml
+shell: true
+```
+
+The VM stays alive after tasks complete, even on success. Use this when iterating; remove before committing for real.
+
+You can also SSH in *while the build is running* to watch progress interactively, run `top`, inspect the filesystem mid-build, etc.
+
+## `complete-build` for early exit
+
+Magic in-VM command that ends the build successfully without running subsequent tasks:
+
+```yaml
+tasks:
+ - check-branch: |
+ if [ "$GIT_REF" != "refs/heads/master" ]; then
+ complete-build
+ fi
+ - deploy: |
+ # only runs on master
+```
+
+It exits the *task* with status 0 and tells the runner to skip all subsequent tasks. The build is marked successful. Use for "this push doesn't need a full build" cases.
+
+Not for security gating — anyone editing the manifest can remove the `complete-build` call.
+
+## Common errors and what they mean
+
+### "No such image: foo/bar"
+
+The `image:` value isn't a valid sourcehut image. Check the spelling against `https://man.sr.ht/builds.sr.ht/compatibility.md`. Common typos: `alpine/3.18` (real) vs `alpine/3.18.0` (not real); `debian/bookworm` (real) vs `debian/12` (not real).
+
+### "Cannot find package: xyz"
+
+Package isn't in the image's repos under that name. Cross-distro names differ:
+
+- Alpine: `nodejs` for Node, `npm` separate.
+- Debian: `nodejs` includes `npm` since recent versions.
+- Arch: `nodejs` and `npm` both.
+
+When unsure: `image: alpine/edge` + `packages: [xyz]`, push, see the error, find the right name via `https://pkgs.alpinelinux.org/packages`.
+
+### "Permission denied (publickey)"
+
+Trying to SSH/git over SSH without a key, or with the wrong key.
+
+- Secret SSH key not configured: verify `secrets:` includes the right UUID and the secret type is "SSH key".
+- Public key not added on the receiving end: for GitHub mirror, add the build's public key (printed by `ssh-keygen -y -f ~/.ssh/id_*` inside the VM) as a deploy key on GitHub.
+- Wrong known_hosts: `ssh-keyscan -H <host> >> ~/.ssh/known_hosts` before the SSH call.
+
+### "401 Unauthorized" from a hut command or curl with `$OAUTH2_TOKEN`
+
+- `oauth:` directive missing or insufficient. Check the scope: read operations need `:RO`, write operations need `:RW`.
+- The OAuth grant is for a different service than you're calling.
+- Build was submitted in a context that disables secrets/OAuth (e.g. mailing-list patch test, `hut builds submit --no-secrets`, web "disable secrets" checkbox). When secrets are off, neither `~/.config/hut/config` nor `$OAUTH2_TOKEN` is provisioned.
+
+### "missing access-token" from hut, even though `$OAUTH2_TOKEN` is set
+
+`hut` does **not** read `$OAUTH2_TOKEN`. It reads `~/.config/hut/config`. The worker pre-writes that file only when `oauth:` is in the manifest **and** secrets are enabled. If the env var is set but `hut` fails, something in your script removed/overwrote the config, or you're running `hut` as a user other than `build`. Inspect `~/.config/hut/config` to confirm. See `references/hut.md`.
+
+### "Build failed with exit code 137"
+
+OOM kill. The VM ran out of memory. The VM's memory size is an instance/operator setting (`builds.sr.ht::worker` config), not a per-manifest value — there's no manifest key to bump it. Upstream `builds.sr.ht.org` runs a fixed amount per VM; self-hosted instances vary. Workarounds, in order of effort:
+
+1. Reduce parallelism inside the build (`make -j2` instead of `make -j$(nproc)`).
+2. Tell the compiler to use less memory (`go build -p 1`, `cargo build -j 1`, `cc -O1` instead of `-O3`, etc.).
+3. Split the work across multiple jobs in `.builds/`.
+4. Run a self-hosted runner on bigger hardware.
+
+### Tar/pages publish accepts but site is broken
+
+pages.sr.ht silently discards invalid uploads. Verify the tarball:
+
+```bash
+tar -tzvf site.tar.gz | head -20
+```
+
+Every line should look like `-rw-r--r--` (mode 644), no `drwx` directories with weird modes, no `l` (symlinks), and the top-level entries should be files (`index.html`, etc.), not a directory like `public/`.
+
+### "skip-ci doesn't seem to work"
+
+git push options need protocol v2 (default since git 2.26). If you're stuck on a very old git, pass `-c protocol.version=2` explicitly:
+
+```bash
+git -c protocol.version=2 push -o skip-ci
+```
+
+Also: some middleboxes (mirroring services, certain proxies) strip push options entirely. If you push to a *mirror* that re-pushes to git.sr.ht, the options don't make it through — push directly to git.sr.ht.
+
+If you want `skip-ci` to be the default for a repo (e.g. for an auto-changelog branch), set it in `git config`:
+
+```bash
+git config --add push.pushOption skip-ci
+```
+
+…and remember to override it (`-o '' ` or unset the config) when you do want a build.
+
+### "Variable from previous task is undefined"
+
+Tasks are separate sessions. Variables `export`ed in one task don't persist. Write to `~/.buildenv`:
+
+```yaml
+tasks:
+ - compute: |
+ VERSION=$(...)
+ echo "VERSION=$VERSION" >> ~/.buildenv
+ - use: |
+ echo "Version is $VERSION"
+```
+
+### "Source directory not found"
+
+The `sources:` URL was wrong, the ref doesn't exist, or you cloned the repo but tried `cd <wrong-dir>`. The clone directory is named after the last URL component: `https://git.sr.ht/~user/myproject` → `myproject/`. Custom names aren't supported via `sources:`; use a task `git clone` for that.
+
+### Build hangs forever
+
+A task that's waiting for user input hangs until the per-job timeout elapses (instance config; the upstream config example uses `45m`, your self-hosted instance may differ). Check for: unattended `apt-get` (use `apt-get install -y`), interactive `make menuconfig`, prompts from `gpg --gen-key` without `--batch`, `npm` asking before installing a dependency, etc.
+
+When the job times out it ends with status `timeout` (treated as failure by triggers), prints the SSH connect line, and gives you the standard 10-minute grace window to log in and look around.
+
+## Iteration workflow
+
+The slow way: edit `.build.yml`, commit, push, wait for build, read log, repeat. Each iteration takes minutes.
+
+The fast way:
+
+1. Go to `https://builds.sr.ht/submit`.
+2. Paste your manifest.
+3. Click submit. The job runs. Watch the log streaming live.
+4. On failure, SSH into the VM, fix manually, write down what worked.
+5. Repeat in the web form with the corrected manifest.
+6. Once green, commit the working manifest as `.build.yml`.
+
+This avoids polluting your git history with "fix CI try 7" commits.
+
+For local iteration, `hut builds submit --follow .build.yml` does the same thing from the CLI, streaming the log to your terminal.
+
+## Reproducing locally
+
+The build images are public. You can pull them locally with QEMU if you want to reproduce a build environment exactly:
+
+```bash
+# Image scripts are in the builds.sr.ht repo
+git clone https://git.sr.ht/~sircmpwn/builds.sr.ht
+cd builds.sr.ht/images/<image-name>
+# Build the image with the genimg script (requires QEMU + the right tooling)
+```
+
+Most people don't go this far. For "is this an environment issue or a code issue", a local Docker run with `docker run -it alpine sh` followed by manually running the build steps catches 90% of issues.
+
+## When to ask for help
+
+The sourcehut admins are helpful but expect:
+
+- **Push UUID** if it's a push problem: `git push -o debug` prints it.
+- **Job URL** for build problems: `https://builds.sr.ht/~user/job/N`.
+- **The manifest** itself, in the message body.
+- **What you've tried**: SSH'd in? Read the log? Tried locally?
+
+The `sr.ht-discuss` mailing list is the right venue for general questions. `sr.ht-support` is for account and billing issues.
A skills/sourcehut-ci/references/hut.md => skills/sourcehut-ci/references/hut.md +315 -0
@@ 0,0 1,315 @@
+# hut — the SourceHut CLI in builds.sr.ht
+
+`hut` (https://git.sr.ht/~xenrox/hut) is the official-in-practice SourceHut CLI: a single Go binary that talks GraphQL to every sr.ht service (builds, git, hg, hub, lists, meta, pages, paste, todo). It is **not** part of the `~sircmpwn/sourcehut` umbrella — it lives under `~xenrox` — but builds.sr.ht has first-class support for it, and most useful CI integrations (publishing pages, downloading artifacts, submitting follow-up builds, applying patchsets) go through `hut`.
+
+This file documents what is and is not true about running `hut` from inside a builds.sr.ht job. For local/interactive `hut` usage, the upstream man page (`hut.1.scd`) is the better reference.
+
+## How `hut` authenticates in CI — the real mechanism
+
+Common misconception: "`hut` picks up `$OAUTH2_TOKEN` from the environment automatically." That's **wrong**. `hut` only reads its token from a config file (`$XDG_CONFIG_HOME/hut/config`, normally `~/.config/hut/config`) — there is no env-var fallback in `hut`'s code.
+
+What actually happens: when a manifest has `oauth:` set **and** secrets are enabled for the build, the builds.sr.ht worker:
+
+1. Mints a short-lived OAuth 2.0 bearer token with exactly the requested grants, expiring at the build deadline.
+2. Writes `/home/build/.config/hut/config` with mode `0600`, containing:
+ - `instance "<site-name>"` (from the running sr.ht instance's `sr.ht/site-name` config — `default` if unset)
+ - `access-token "<the minted token>"`
+ - A `<service> { origin "<service-origin>" }` block for **every** `<name>.sr.ht` section that has `api-origin` (preferred) or `origin` in the instance's config — except `man.sr.ht`, which is excluded (the worker has a `TODO` to add it once `hut` supports it).
+3. Exports `$OAUTH2_TOKEN` in `~/.buildenv` with the same token, for use with `curl --oauth2-bearer` or any other tool that doesn't read a `hut`-style config file.
+
+So in a job, both work, but they reach the API differently:
+
+```bash
+hut builds list # uses ~/.config/hut/config
+curl --oauth2-bearer "$OAUTH2_TOKEN" \ # uses the env var
+ https://meta.sr.ht/query -d '{...}'
+```
+
+The pre-written config is the load-bearing thing for `hut`. If a step removes it, `chmod`s it unreadable, or runs as a different user, `hut` will fail with "missing access-token" even though `$OAUTH2_TOKEN` is set. Source: `builds.sr.ht/worker/tasks.go` `SendHutConfig()`.
+
+### Self-hosted instances work out of the box
+
+Because the worker reads service origins from the same instance config the rest of the service uses, a self-hosted SourceHut (e.g. `*.srht.bigb.es`) gets a `hut` config pointing at *its own* services, not `sr.ht`. No extra plumbing required — `oauth: pages.sr.ht/PAGES:RW` in a manifest running on `builds.srht.bigb.es` produces a config that points `pages → https://pages.srht.bigb.es`.
+
+If `hut` ever appears to be talking to `sr.ht` instead of the local instance, check that the running builds-worker actually has the per-service `api-origin` / `origin` keys set in `/etc/sr.ht/config.ini` — without them, the service block isn't emitted and `hut` falls back to `service.<instance-name>` which won't resolve.
+
+### When `hut` config is NOT written
+
+`SendHutConfig` early-returns when `oauth2Token()` returns nil. That happens whenever either:
+
+- The manifest has no `oauth:` directive, **or**
+- The build was submitted with secrets disabled (`hut builds submit --no-secrets`, the web "disable secrets" checkbox, or a manifest containing `secrets: false` in some pipelines).
+
+In those cases neither `~/.config/hut/config` nor `$OAUTH2_TOKEN` exists. `hut` will exit with `error: missing access-token` and `curl --oauth2-bearer "$OAUTH2_TOKEN"` will send `Bearer ` with an empty token.
+
+There is no override and no way to "borrow" a token from another build. If you need `hut` to work, you need `oauth:` and you need secrets enabled.
+
+## Installing `hut` in a build
+
+| Image | How |
+| -------------------- | ------------------------------------------------ |
+| `alpine/edge`, `alpine/latest` | `packages: [hut]` — in the main repo, current. |
+| `archlinux` | `packages: [hut]` — community/extra repo. |
+| `freebsd/latest` | `packages: [hut]` — ports. |
+| `nixos/unstable` | `packages: [hut]`. |
+| `debian/*`, `ubuntu/*` | **Not packaged.** See below. |
+| `fedora/*` | **Not packaged.** Build from source or use a release tarball. |
+
+For Debian/Ubuntu/Fedora, the cleanest options are:
+
+```yaml
+# Option 1: download a tagged release binary
+tasks:
+ - install-hut: |
+ ver=v0.8.0
+ curl -L "https://git.sr.ht/~xenrox/hut/refs/download/$ver/hut-${ver#v}-linux-amd64" \
+ -o /tmp/hut
+ chmod +x /tmp/hut
+ sudo mv /tmp/hut /usr/local/bin/hut
+ hut version
+```
+
+```yaml
+# Option 2: go install from source — slow but no version drift
+packages: [golang, scdoc]
+tasks:
+ - install-hut: |
+ git clone https://git.sr.ht/~xenrox/hut
+ cd hut && make && sudo make install
+```
+
+Pin a version. The `refs/download/<tag>/...` URL is stable per tag; `master` is not. Latest tag at the time of writing is **v0.8.0**.
+
+## CI-relevant commands and their OAuth scopes
+
+The OAuth scope syntax is `service/SCOPE[:MODE]` (multiple space-separated). For a complete scope list per service, query `meta.sr.ht/query` interactively — the table below is what's actually useful in CI.
+
+### pages — publish a static site
+
+```yaml
+oauth: pages.sr.ht/PAGES:RW
+```
+
+```
+hut pages publish -d <fqdn> [-s <subdir>] [-p https|gemini] [--site-config <path>] <tarball-or-dir>
+hut pages unpublish -d <fqdn> [-p https|gemini]
+hut pages list
+hut pages acl list -d <fqdn>
+hut pages acl update <user> [--id <site-id>] [--publish]
+```
+
+Input to `publish` can be a `.tar.gz` or a directory; passing `-` (or omitting the path on a non-terminal stdin) reads from stdin. **No symlinks, no special files, mode 644 only** — `pages.sr.ht` rejects them. The reliable invocation is:
+
+```bash
+tar -czf site.tar.gz -C <output-dir> .
+hut pages publish -d "$site" site.tar.gz
+```
+
+`-s <subdir>` does a partial update — it touches only files under that path on the site, leaving the rest in place. Use this only when you actually want partial deployment; full-site publishes are atomic and rollback-safe.
+
+### builds — submit, follow, fetch artifacts of other jobs
+
+```yaml
+oauth: builds.sr.ht/JOBS:RW
+```
+
+```
+hut builds submit [-f] [-n <note>] [-s] [-t <tags>] [-v public|unlisted|private] [manifest...]
+hut builds resubmit <id> [-e] [-f] [-n <note>] [-s] [-v ...]
+hut builds list [owner] [--count N] [-s <status>] [-t <tag-prefix>]
+hut builds show [id] [-f] [--web]
+hut builds artifacts <id> # lists artifacts of a job
+hut builds cancel <id...>
+hut builds ssh <id> # local-only, not useful in CI
+hut builds update <id> [-v ...] [-t <tags>]
+hut builds secret list [--count N]
+hut builds secret share <secret> -u <user>
+```
+
+`builds submit` without arguments auto-discovers `.build.yml`, `.build.yaml`, `.builds/*.yml`, `.builds/*.yaml` in the cwd. Pass `-f` (`--follow`) to stream logs and exit non-zero on a failed build — that's how to chain builds with hard dependencies. The minted CI token has `JOBS:RW` only if you asked for it; downgrade to `JOBS:RO` if you only need `list`/`show`/`artifacts`.
+
+`builds artifacts <id>` returns JSON-ish output with download URLs that require the same token; pair with `curl --oauth2-bearer "$OAUTH2_TOKEN" -L -o out.tar.gz <url>` to actually fetch.
+
+### git — repo metadata, artifacts attached to refs
+
+```yaml
+oauth: git.sr.ht/REPOSITORIES:RW # full
+oauth: git.sr.ht/OBJECTS:RW # artifact upload only
+```
+
+```
+hut git artifact upload <file...> [--rev <tag>]
+hut git artifact list
+hut git artifact delete <id>
+hut git acl list / update / delete
+hut git create / delete / update / show / list
+hut git clone / setup # local-only
+```
+
+`git artifact upload` attaches files to a git tag (`--rev`; defaults to the latest tag in cwd). This is the CI-friendly way to ship release tarballs/binaries: tag → build → upload. The attached artifact is visible on the tag's page.
+
+### lists — apply / update patchsets, test contributions
+
+```yaml
+oauth: lists.sr.ht/PATCHES:RW
+```
+
+```
+hut lists patchset apply <id> # writes patches into cwd
+hut lists patchset update <id> --status <NEEDS_REVISION|APPROVED|REJECTED|APPLIED|...>
+hut lists patchset show <id>
+hut lists patchset list <list>
+```
+
+Use case: a CI manifest triggered by a mailing-list webhook clones the repo, runs `hut lists patchset apply <id>`, then `git am` / `git apply`, then runs tests. On failure, `hut lists patchset update <id> --status NEEDS_REVISION` and email the thread.
+
+### todo — create/close tickets from build outcomes
+
+```yaml
+oauth: todo.sr.ht/TRACKERS:RW
+```
+
+```
+hut todo ticket create -t <tracker> -s "<subject>" [-b <body>]
+hut todo ticket update <ticket-ref> --status <STATUS> --resolution <RESOLUTION>
+hut todo ticket comment <ticket-ref> -b <body>
+hut todo ticket assign <ticket-ref> -u <user>
+hut todo ticket list -t <tracker>
+```
+
+Useful for "open a ticket on flaky test" / "close the auto-generated ticket on green build" patterns.
+
+### paste — short-lived dumps from the build
+
+```yaml
+oauth: paste.sr.ht/PASTES:RW
+```
+
+```
+hut paste create [-n <name>] [-v public|unlisted|private] <file...>
+hut paste list
+hut paste delete <id>
+```
+
+Quick way to ship a log excerpt out of a build for inspection without setting up artifacts.
+
+### meta — query identity, ssh keys, pgp keys
+
+```yaml
+oauth: meta.sr.ht/PROFILE:RO # to read 'whoami'
+```
+
+```
+hut meta show [user]
+hut meta ssh-key list / create / delete
+hut meta pgp-key list / create / delete
+hut meta audit-log
+hut meta api-key list / create / delete # NEW since v0.7
+```
+
+Rarely needed from CI. Most useful for debugging "what user is this OAuth token actually authenticated as" — `hut meta show` returns the canonical name attached to the token.
+
+### graphql — escape hatch for anything not covered
+
+```
+hut graphql <service> [-v key=value] [--stdin] [--file key=path] <<EOF
+query { ... }
+EOF
+```
+
+Reads a query from stdin (default if non-terminal) or `$EDITOR` and posts to `<service>.sr.ht/query` with the configured token. Use this when you need a mutation `hut` doesn't have a subcommand for (e.g. `submit` with manifest-as-string, group creation, custom webhook events). The token's grants still apply — `hut graphql` does not bypass scope checks.
+
+## Useful CI patterns
+
+### Self-publishing release with `git artifact upload`
+
+```yaml
+image: alpine/edge
+packages: [go, hut]
+oauth: git.sr.ht/OBJECTS:RW
+sources: [https://git.sr.ht/~user/mytool]
+tasks:
+ - build: |
+ cd mytool
+ tag=$(git describe --tags --abbrev=0)
+ GOOS=linux GOARCH=amd64 go build -o "mytool-$tag-linux-amd64" ./cmd/mytool
+ GOOS=darwin GOARCH=arm64 go build -o "mytool-$tag-darwin-arm64" ./cmd/mytool
+ - publish: |
+ cd mytool
+ hut git artifact upload --rev "$(git describe --tags --abbrev=0)" mytool-*-*
+```
+
+Pair with `submitter.allow-refs: ["refs/tags/*"]` so this only fires on tag pushes.
+
+### Follow-up build after tests pass (no `job:` trigger needed)
+
+```yaml
+image: alpine/edge
+packages: [hut]
+oauth: builds.sr.ht/JOBS:RW
+sources: [https://git.sr.ht/~user/myproject]
+tasks:
+ - test: |
+ cd myproject && make test
+ - deploy: |
+ cd myproject && hut builds submit -f .builds/deploy.yml
+```
+
+The `-f` flag makes `hut` wait for the deploy job and exit non-zero on failure, so the parent build accurately reflects the deploy result.
+
+### Fetch an artifact produced by a previous build
+
+```yaml
+oauth: builds.sr.ht/JOBS:RO
+tasks:
+ - fetch: |
+ # Replace with the actual job id (e.g. from a webhook payload).
+ hut builds artifacts 1234567 \
+ | awk '/mybinary/ { print $NF }' \
+ | xargs -I{} curl -L --oauth2-bearer "$OAUTH2_TOKEN" -o mybinary {}
+```
+
+`hut builds artifacts` currently prints a human-readable list rather than machine-readable JSON; if you need stable parsing, use `hut graphql builds` with the `job(id: <id>) { artifacts { ... } }` selector instead.
+
+### Per-job tags for filtering
+
+`-t name/scope` attaches slash-separated tags to a job. Useful when you submit many follow-up jobs and want `hut builds list -t myproject/deploy` to filter them.
+
+## Common failure modes
+
+**`error: missing access-token` from `hut`**
+The manifest has no `oauth:` directive, or secrets are disabled. Check the build's first few log lines — if you don't see "Sending hut config" / equivalent, no config was written. Fix: add `oauth: <scope>` (the smallest scope that works) and resubmit *with* secrets enabled. Personal-access-token via `secrets:` is a workable fallback but strictly worse — manage rotation yourself.
+
+**`hut` talks to `sr.ht` instead of the local instance (self-hosted)**
+The local builds-worker's config is missing per-service `api-origin` / `origin` entries. `SendHutConfig` only emits a service block when one of those keys is present; without them, `hut` constructs the origin from the bare instance name, which won't resolve to your hosts. Fix: add `[<service>.sr.ht]` sections with `origin` (and ideally `api-origin`) in `/etc/sr.ht/config.ini` and restart the worker.
+
+**`hut pages publish` returns 401**
+Token has the wrong grant. `PAGES:RW` is required to publish; `PAGES:RO` only reads. There is no separate publish-vs-unpublish split.
+
+**`hut pages publish` returns 400 "invalid tarball" / "symlink rejected" / "mode not allowed"**
+pages.sr.ht enforces: only regular files and directories; mode `0644` (files) / `0755` (dirs); no `./` prefix beyond what `tar -C dir .` produces; no extra top-level wrapper directory. A common offender: `tar -czf out.tar.gz public/` — this nests everything under `public/` on the site. Use `tar -czf out.tar.gz -C public .` instead.
+
+**`hut builds submit -f` exits 0 even though the child build failed**
+`-f` follows the job and the exit code reflects the job's `status`. If the job was cancelled rather than failed, `hut` (older versions) exited 0; v0.7+ exits non-zero on `CANCELLED` too. Pin a recent version if you depend on this.
+
+**`hut` v0.x command exists in docs but `hut --help` says no such command**
+The image's `hut` package is older than the docs you're reading. `apk` and `archlinux` images stay close to upstream; Debian/Ubuntu have no package at all so version drift only happens if you forget to bump your `install-hut` step. Always run `hut version` early to log what's actually installed.
+
+**Token leaks into the log via `set -x`**
+`~/.buildenv` exports `OAUTH2_TOKEN`, and `set -x` is on by default. Any command that mentions `$OAUTH2_TOKEN` echoes the expanded value. For commands that take the token from the config (`hut`) this is a non-issue; for `curl --oauth2-bearer "$OAUTH2_TOKEN"` it is. Wrap such commands:
+
+```bash
+set +x
+curl --oauth2-bearer "$OAUTH2_TOKEN" https://meta.sr.ht/query -d "$query"
+set -x
+```
+
+The token is short-lived (expires at the build deadline, typically ~4h), so a leaked one isn't a long-term credential — but it's still worth not leaking.
+
+## Cross-references
+
+- `references/pages.md` — full `pages.sr.ht` workflow, tarball constraints, multi-domain publishing.
+- `references/secrets-and-oauth.md` — why prefer `oauth:` over `secrets:`, full scope reference, examples without `hut`.
+- `references/manifest.md` — the `oauth:` directive field reference.
+- `references/integrations.md` — `git.sr.ht` push hooks, mailing-list patch testing, `createGroup` for cross-job triggers.
+- `references/debugging.md` — interpreting log output when `hut` (or anything else) goes wrong.
A skills/sourcehut-ci/references/images.md => skills/sourcehut-ci/references/images.md +188 -0
@@ 0,0 1,188 @@
+# Choosing an `image:`
+
+Pick the smallest, fastest image that has the packages you need. This page is a quick selector.
+
+## Default recommendation
+
+**`alpine/edge`** unless you have a specific reason otherwise. Boot is fast (~5 seconds), `apk` install is fast, packages are recent. Most projects work on Alpine.
+
+`alpine/latest` is also fine if you want the latest stable rather than edge (rolling).
+
+## When to switch off Alpine
+
+- **glibc-required binaries** (you have a pre-built binary that depends on glibc). Alpine uses musl. Switch to `debian/stable` or `ubuntu/lts`.
+- **Distro-specific packaging** — building a `.deb` → `debian/stable`; building a `.rpm` → `fedora/latest`; AUR things → `archlinux`.
+- **The package you need is in this distro and not Alpine** — happens occasionally. Search the relevant distro's package index.
+- **Reproducing a customer's environment** — match their distro.
+
+## Self-hosted instances (`*.srht.bigb.es` and similar)
+
+Upstream `builds.sr.ht.org` ships a large pre-built image catalog. A self-hosted instance ships only:
+
+1. **Recipes** — shell scripts under `/var/lib/images/<distro>/<version>/` installed by the `builds.sr.ht-images` Alpine package. These describe how to build a qcow2 but are not themselves bootable.
+2. **Whatever the operator has actually built** with `genimg`. Each recipe must be run once (and re-run when you want a fresh base) to produce `/var/lib/images/<distro>/<version>/<arch>/root.img.qcow2`.
+
+Submitting `image: <distro>/<version>` against a self-hosted instance fails with "no such image" until the qcow2 has been built. Before recommending an image for someone's self-hosted instance, check whether they've built it. The skill's surrounding `phoebe-lab/srht` repo exposes `just list-built-images` for this.
+
+Self-hosted instances also commonly ship a **subset** of the upstream catalog. The `builds.sr.ht-images` apk package (the source of recipes) does not include `9front` or `gentoo`; instances that want those have to add them manually. Don't assume an image exists on a self-hosted instance just because it works on `builds.sr.ht.org`.
+
+## Per-distro notes
+
+### Alpine (`alpine/*`)
+
+- Package manager: `apk`. Install: `apk add <pkg>` (the manifest's `packages:` does this).
+- musl libc, BusyBox utils. Some scripts assume GNU coreutils — install `coreutils` if a script uses `--features` not in BusyBox.
+- Aliases (as of upstream compatibility matrix, 2026):
+ - `alpine/edge` — rolling, daily refresh.
+ - `alpine/latest` = `alpine/3.23` (current stable).
+ - `alpine/old` = `alpine/3.22`, `alpine/older` = `alpine/3.21`, `alpine/oldest` = `alpine/3.20`.
+- `alpine/edge` boots in ~5 seconds. Tiny image.
+- `python3` requires `py3-pip` separately. Go is `go`. Node is `nodejs` + `npm`. Rust is `rust` + `cargo`.
+- Custom apk repositories use the three-word form `repo-url key-url key-name` in `repositories:`.
+
+### Arch (`archlinux`)
+
+- Package manager: `pacman`. Rolling release.
+- AUR not available out of the box; install `yay` or `paru` via packages, then build.
+- Often the cleanest for "I just need a recent version of <tool>" because rolling.
+- Hugo is the extended (Sass-capable) build here, unlike Alpine.
+
+### Debian (`debian/<codename>`)
+
+- Package manager: `apt`. Older but stable packages.
+- `apt-get install` needs `-y` to skip prompts; the manifest's `packages:` handles this.
+- Build essentials are in `build-essential`.
+- Python is `python3` + `python3-pip`. Node is `nodejs` + `npm` (modern Debian).
+- Aliases (2026):
+ - `debian/stable` = `debian/trixie` (Debian 13).
+ - `debian/oldstable` = `debian/bookworm` (Debian 12).
+ - `debian/testing` = `debian/forky`.
+ - `debian/unstable` = `debian/sid`.
+- Both `debian/stable` / `debian/testing` and the codenames are valid; pick whichever you'd rather not have to update in two years.
+- Default arch is `amd64`, not `x86_64`.
+
+### Ubuntu (`ubuntu/<codename>`)
+
+- Like Debian, but Canonical's spin. `apt` for packages.
+- Aliases (2026):
+ - `ubuntu/lts` = `ubuntu/noble` (24.04 LTS).
+ - `ubuntu/oldlts` = `ubuntu/jammy` (22.04 LTS).
+ - Other shipped: `focal` (20.04 LTS), `oracular` (24.10), `plucky` (25.04).
+- Default arch is `amd64`, not `x86_64`. Slower boot than Alpine. Larger image.
+
+### Fedora (`fedora/<version>`)
+
+- Package manager: `dnf`. Recent packages.
+- Aliases (2026):
+ - `fedora/latest` = `fedora/43`.
+ - `fedora/rawhide` = `fedora/44` (pre-release).
+ - Older shipped: `fedora/42`.
+- The `repositories:` invocation differs between Fedora 41+ (`dnf config-manager addrepo`) and Fedora 40- (`dnf config-manager --add-repo`); the worker handles it but the `.repo` file format varies.
+- Common for RPM packaging and Red Hat-adjacent workflows.
+
+### Rocky Linux (`rockylinux/<version>`)
+
+- Package manager: `dnf`. RHEL-compatible (replaces CentOS for RHEL-clone testing).
+- Versions shipped: `rockylinux/8`, `rockylinux/9`.
+- Use when you need to validate against RHEL-like userspace without RHEL licensing.
+
+### FreeBSD (`freebsd/<version>`)
+
+- Package manager: `pkg`. BSD make and BSD coreutils.
+- Aliases (2026):
+ - `freebsd/latest` = `freebsd/15.x`.
+ - `freebsd/current` = `freebsd/16.0-CURRENT` (pre-release).
+ - Also shipped: `freebsd/14.x` (14.4-RELEASE).
+- Default arch is `amd64`, not `x86_64`. Custom package repositories are **not supported** — only the standard `pkg` repos work.
+- Useful for cross-platform testing or BSD-targeted builds.
+- Some GNU tools have different flag syntax; check before relying on them.
+
+### OpenBSD (`openbsd/<version>`)
+
+- Package manager: `pkg_add`. Base system gets `syspatch` for binary patches.
+- Aliases (2026): `openbsd/latest` = `openbsd/7.8`, `openbsd/old` = `openbsd/7.7`.
+- Custom package repositories are not supported.
+- Similar use case to FreeBSD; smaller, tighter system.
+
+### NetBSD (`netbsd/<version>`)
+
+- pkgsrc via `pkgin`.
+- Aliases (2026): `netbsd/latest` = `netbsd/10.x` (10.0). Also: `netbsd/9.x` (9.3).
+- Custom package repositories are not supported.
+- Useful for portability testing across all the BSDs.
+
+### NixOS (`nixos/<channel>`)
+
+- Nix works out of the box; **flakes are not enabled by default** — set `NIX_CONFIG: "experimental-features = nix-command flakes"` in `environment:` to opt in.
+- `packages:` uses `nix-env -iA`, so you need a selection path: `nixos.hello`, not bare `hello`.
+- Channels shipped (2026): `nixos/unstable`, `nixos/latest` = `nixos/25.05`, also `nixos/24.11`.
+- Custom channels via `repositories:` (`channel-name: channel-url`) run `nix-channel --add` + `--update` for you. The pre-installed root channel is `nixos`, pointing at the image's release (or `nixos-unstable` for `nixos/unstable`).
+- Best image for reproducible builds; once flakes are on, `nix build .#mypackage` is the whole task.
+
+### Guix (`guix`)
+
+- Similar to NixOS but with Guile-based config. Niche but supported.
+- No version segment — single rolling recipe.
+
+### Gentoo and 9front
+
+The upstream sr.ht-docs compatibility matrix (2026) lists these distros for builds.sr.ht:
+
+Alpine, Arch, Debian, Fedora, FreeBSD, Guix, NetBSD, NixOS, OpenBSD, Rocky Linux, Ubuntu.
+
+It does **not** list Gentoo or 9front. The `builds.sr.ht` repo's `images/` directory contains recipes for the distros above plus `qemu` and a `control` script — no `gentoo/` or `9front/` directories. Submitting `image: gentoo` or `image: 9front` will fail on every standard sr.ht instance (upstream included) with "no such image" until an operator adds and builds those recipes by hand. Do not generate manifests targeting them by default.
+
+## Architecture support
+
+Default is `x86_64`. Other architectures are available on a subset of images:
+
+- `aarch64` — available on `alpine/*`, recent `debian/*`, recent `freebsd/*`, `archlinux` (varies).
+- `riscv64` — `alpine/edge` typically.
+- Others (`armv7`, `ppc64le`) — case-by-case, check the compatibility page.
+
+To use:
+
+```yaml
+image: alpine/edge
+arch: aarch64
+```
+
+Not every image+arch combination exists. If submission fails with "no such image", the combination isn't supported.
+
+### How arch routing actually works
+
+On the worker, qcow2 disks live at `/var/lib/images/<distro>/<version>/<arch>/root.img.qcow2`. The `/var/lib/images/control` script reads `arch:` from the job, checks that path, and:
+
+- If host arch matches guest arch and `/dev/kvm` exists → KVM acceleration (~native speed).
+- If they differ → QEMU TCG software emulation (~5–20× slower).
+
+Recipes (`<distro>/<version>/functions`) gate which archs they support. Most upstream recipes default to `x86_64` and explicitly reject other arches — e.g. Alpine's `functions` has `default_arch=x86_64` and a hard error otherwise. So "this distro supports aarch64" depends on the recipe, not just the worker's QEMU binaries.
+
+For self-hosted instances: adding a new arch means (a) installing `qemu-system-<arch>` in the worker, (b) patching each recipe's `functions` to drop the arch guard and fetch arch-specific bootstrap, and (c) rebuilding the qcow2 under the new arch directory. Performance on a mismatched host (e.g. aarch64 on x86_64 hardware) makes this mostly useful for "does it compile?" CI, not real workloads.
+
+For real cross-arch CI, the right pattern is a **separate worker on native hardware** — builds.sr.ht is multi-worker and the scheduler routes jobs by the `arch:` field to the worker that has a matching built image.
+
+## Trade-off summary
+
+| Image | Boot speed | Image size | Package recency | Best for |
+|---|---|---|---|---|
+| `alpine/edge` | ★★★★★ | Tiny | Recent | Default |
+| `archlinux` | ★★★★ | Medium | Bleeding-edge | Rolling deps, AUR |
+| `debian/stable` | ★★★ | Medium | Stable, older | Debian packaging, glibc |
+| `ubuntu/lts` | ★★ | Larger | Stable | Canonical workflows |
+| `fedora/latest` | ★★★ | Medium | Recent | RPM packaging |
+| `freebsd/latest` | ★★ | Medium | Recent | BSD testing |
+| `nixos/unstable` | ★★ | Larger | Rolling | Reproducible builds |
+
+## Verifying package availability
+
+Before committing a manifest, verify the package name in the target image's repos:
+
+- Alpine: <https://pkgs.alpinelinux.org/packages>
+- Arch: <https://archlinux.org/packages>
+- Debian: <https://packages.debian.org>
+- Ubuntu: <https://packages.ubuntu.com>
+- Fedora: <https://packages.fedoraproject.org>
+- FreeBSD: <https://www.freebsd.org/ports>
+
+Package names vary: `ninja` (Alpine, Arch, Fedora) vs `ninja-build` (Debian, Ubuntu). When in doubt, look it up — the failure mode of guessing is a wasted CI run, but the lookup takes 5 seconds.
A skills/sourcehut-ci/references/integrations.md => skills/sourcehut-ci/references/integrations.md +326 -0
@@ 0,0 1,326 @@
+# Integrations and Build Submission
+
+Builds.sr.ht is a job runner. Multiple things can submit jobs to it: git.sr.ht on push, the project hub on patch email, the web form, `hut`, the GraphQL API. This file covers how each works.
+
+## git.sr.ht auto-submission
+
+When you push to a git.sr.ht repo, the post-update hook walks the new refs in the push, parses any matching manifests out of each new commit's tree, and fires a GraphQL `submit` mutation against builds.sr.ht for each.
+
+The default discovery glob is comma-separated and covers both extensions:
+
+```
+.build.yml,.builds/*.yml,.build.yaml,.builds/*.yaml
+```
+
+- A single `.build.yml`/`.yaml` at the repo root is the common case.
+- `.builds/*.yml` (or `.yaml`) supports multiple manifests. Each becomes an independent job.
+- The hook caps total submissions at **4 per push, summed across every ref in the push**. So a push that touches three branches each carrying two manifests still gets at most 4 jobs total; the rest are silently dropped. When more than 4 are present in a single ref's manifest set, the hook reads them into a Go map and iterates — so "chosen randomly" is the practical effect.
+- Override the discovery pattern per-push with `git push -o submit=".sourcehut/*.yml"` (fnmatch syntax, comma-separated).
+
+Before submission, the hook rewrites the manifest:
+
+1. **`sources:` injection.** For each entry whose basename equals the pushed repo's name, the URL is replaced with the canonical clone URL plus `#<commit-sha>` of the pushed commit. If no entry matches, the clone URL is *appended* to `sources:`. Private repos get the SSH form (`git+ssh://git@<host>/~<owner>/<repo>`); public repos get HTTPS. The commit-sha pin guarantees the build runs against exactly what you pushed.
+2. **`environment:` augmentation.** `BUILD_SUBMITTER=git.sr.ht` and `GIT_REF=<pushed ref>` are added (and overwrite any same-named keys you set in the manifest).
+3. **`shell: true` is stripped**, with a `Notice: removing 'shell: true' from build manifest` line in the log. Use `shell: true` only for manual submissions, never for auto-submitted manifests.
+
+The note attached to each build (used as the email/IRC subject line by post-build triggers) is generated from the commit. It looks like:
+
+```
+[abc1234][0] — [Author Name][1]
+
+ First line of the commit message
+
+[0]: https://<git-host>/~<owner>/<repo>/commit/<sha>
+[1]: mailto:author@example.com
+```
+
+so the dashboard always tells you which commit and who made it, even before you click through.
+
+### Push options
+
+`git push -o <option>` lets you control behavior per-push without changing the manifest:
+
+| Option | Effect |
+|---|---|
+| `skip-ci` | Skip build submission entirely for this push. Push itself succeeds normally. |
+| `submit=<glob>` | Override the default manifest path glob (`.build.yml,.builds/*.yml`). Comma-separated, fnmatch(3) style. |
+| `debug` | Print the push UUID. Useful for support tickets. |
+| `description=<text>` | Set the repository's description. |
+| `visibility=public\|unlisted\|private` | Set the repository's visibility. |
+
+```bash
+git push -o skip-ci
+git push -o submit=".sourcehut/*.yml"
+git push -o description="My cool project"
+```
+
+To make a push option permanent for a repo:
+
+```bash
+git config --add push.pushOption submit=".sourcehut/*.yml"
+```
+
+All push options (recognized or not) are also forwarded to repo webhooks, so custom tooling can hook into them.
+
+### In-manifest submission control
+
+The `submitter:` block in `.build.yml` lets the git.sr.ht push hook filter by ref *at submission time*, so non-matching pushes don't even create a build:
+
+```yaml
+submitter:
+ git.sr.ht:
+ enabled: true # optional, default true
+ allow-refs:
+ - refs/heads/master
+ - refs/heads/main
+ - "refs/tags/*"
+```
+
+Semantics as implemented in `update-hook/submitter.go`:
+
+- **`enabled`**: tri-state. Unset or `true` → permissive. `false` → skip immediately; `allow-refs` is **not** consulted. Writing `enabled: true` is redundant.
+- **`allow-refs`**: list of `fnmatch(3)`-style glob patterns matched against the full ref string (e.g. `refs/heads/master`, `refs/tags/v1.0`). Matched with no flags, so `*` does cross `/`. Absent/empty → no filtering; non-empty → submit only if at least one pattern matches.
+- The block is interpreted by **the git.sr.ht push hook**, not builds.sr.ht. Manual submission via `hut builds submit`, the web submit form, or a direct GraphQL `submit` ignores `submitter:` entirely — the runner does not re-evaluate it.
+- A `hub.sr.ht:` sibling (used by patchset auto-testing) is interpreted by hub.sr.ht's separate codebase, not the git push hook; the git hook silently drops unknown sub-keys when it re-serializes the manifest before sending it to builds.sr.ht.
+- The filter fires after the manifest is parsed from the tree but before the GraphQL submit mutation, so non-matching refs log a `skip: ref X disabled by rule` line and never count toward the 4-job push cap.
+
+This is better than `complete-build` for known-at-push-time filters because it doesn't waste a VM boot.
+
+### Built-in environment for git.sr.ht-submitted builds
+
+- `GIT_REF` — e.g. `refs/heads/master`, `refs/tags/v1.0`
+- `BUILD_SUBMITTER` — `git.sr.ht`
+
+`BUILD_REASON` is **not** set by the git.sr.ht hook (only hub.sr.ht sets it, to `patchset`). Older notes claiming `BUILD_REASON=git_push` are wrong; check the env yourself: `env | grep BUILD_`.
+
+Use these in tasks:
+
+```yaml
+tasks:
+ - check: |
+ case "$GIT_REF" in
+ refs/heads/main) ;;
+ refs/tags/*) ;;
+ *) complete-build ;;
+ esac
+```
+
+## Mailing list patch testing
+
+When a project is associated with a mailing list via the project hub, patches sent to the list are tested automatically.
+
+Setup:
+
+1. Create a project on `hub.sr.ht`.
+2. Link both the repo and the list to the project.
+3. Patches with the subject prefix `[PATCH <reponame>]` get auto-tested against `<reponame>`.
+
+The patch is applied on top of the target branch, builds run, and the result is posted back to the email thread as a reply.
+
+Key behaviors:
+
+- **Secrets are disabled** for patchset-triggered builds. Don't rely on them.
+- Manifest used is the one in the **target repo**, not the patchset.
+- `$BUILD_REASON=patchset`, `$PATCHSET_ID` and `$PATCHSET_URL` are set.
+
+Disable patch testing for a specific manifest:
+
+```yaml
+submitter:
+ hub.sr.ht:
+ enabled: false
+```
+
+The default subject prefix for a repo can be set with `git config format.subjectPrefix 'PATCH project-name'`.
+
+## hut CLI
+
+`hut` is the de-facto SourceHut CLI (from `~xenrox`). It uses OAuth tokens — interactive via `hut init`, or in builds via the `~/.config/hut/config` that the worker provisions when the manifest has an `oauth:` directive. Full CI reference in `references/hut.md`.
+
+Common commands:
+
+```bash
+# Submit a build from a manifest
+hut builds submit .build.yml
+
+# Submit and stream the log
+hut builds submit --follow .build.yml
+
+# Show a recent build
+hut builds show <job-id>
+
+# List your secrets (without revealing values)
+hut builds secret list
+
+# Cancel a job
+hut builds cancel <job-id>
+
+# Publish to pages
+hut pages publish -d example.com site.tar.gz
+
+# List your published sites
+hut pages list
+
+# Unpublish a site
+hut pages unpublish -d example.com
+```
+
+`hut init` runs the OAuth flow for local use. In CI, no `init` is needed — the worker writes `/home/build/.config/hut/config` with the minted token and per-service origins as soon as the manifest has an `oauth:` directive (and secrets are enabled). See `references/hut.md` for the full mechanism, install instructions per image, and the scope-to-command map.
+
+## Web submission
+
+`https://builds.sr.ht/submit` — paste a manifest, click submit, watch it run. The fastest iteration loop for designing a manifest. No commit history pollution.
+
+## GraphQL API
+
+Endpoint: `https://builds.sr.ht/query`. Authentication via OAuth 2.0 bearer token.
+
+Key mutations (user-callable):
+
+- `submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility): Job!` — submit a new job. `execute: false` keeps the job pending so you can `createGroup` it. Requires `JOBS:RW`.
+- `start($jobID: Int!): Job` — queue a pending job. Requires `JOBS:RW`.
+- `cancel($jobId: Int!): Job` — cancel a running/pending job. Requires `JOBS:RW`.
+- `update($jobId: Int!, $visibility: Visibility!, $tags: [String!]): Job` — change tags/visibility after submission.
+- `createGroup($jobIds: [Int!]!, $triggers: [TriggerInput!], $execute: Boolean, $note: String): JobGroup!` — group pending jobs with shared completion triggers.
+- `startGroup($groupId: Int!): JobGroup` — start a deferred job group.
+- `shareSecret($uuid: String!, $user: String!): Secret!` — share a secret with another user. Requires `SECRETS:RW`.
+
+What's **not** user-callable: `createArtifact`, `claim`, `updateJob`, `updateTask`. These are decorated `@worker` in the schema and only accessible to the builds.sr.ht runner itself — there is no way for a user to upload an artifact post-hoc via the API. If `artifacts:` won't work for you, upload to your own storage or `uploadArtifact` (the git.sr.ht mutation, `OBJECTS:RW`) which attaches files to git tags.
+
+The playground at `https://builds.sr.ht/graphql` is the easiest way to explore the live schema for your instance.
+
+## Multi-image builds (the matrix substitute)
+
+There's no matrix syntax. Use separate files in `.builds/`:
+
+```
+.builds/
+├── alpine.yml
+├── debian.yml
+├── freebsd.yml
+└── archlinux.yml
+```
+
+Each file is a standalone manifest. Up to 4 are submitted per push. Each runs independently as its own job; there's no "fan-in" job. To aggregate results, either:
+
+1. **Group them after submission via the API** — submit all 4 with `execute: false`, then `createGroup` them with a shared trigger, then `startGroup`. The trigger fires when all members finish.
+2. **Use a finalizing job triggered by each** — each job's `triggers:` includes a webhook to your own aggregator service.
+
+In practice, separate independent jobs and reading the dashboard is what most projects do.
+
+## Post-build triggers
+
+Only **two** trigger actions exist in current builds.sr.ht: `email` and `webhook`. The Python manifest parser (`buildsrht/manifest.py:TriggerAction`) accepts only these enum values, and the Go worker (`worker/triggers.go:ProcessTriggers`) dispatches only these two — anything else logs `Unknown trigger action 'X'` and is skipped. Older docs and blog posts mention `irc` and `job` triggers; they were removed (or never landed) and **do not work today**.
+
+### `email`
+
+```yaml
+triggers:
+ - action: email
+ condition: failure
+ to: "Me <me@example.com>"
+ cc: "Other <them@example.com>"
+ in_reply_to: "<some-message-id@example.com>" # optional
+```
+
+Subject: `[<tags>] build <status>`. Body lists task statuses with ✓/✗ glyphs and a link to the job page. Sender is taken from instance config (`builds.sr.ht::worker/trigger-from`).
+
+### `webhook`
+
+```yaml
+triggers:
+ - action: webhook
+ condition: always
+ url: https://example.com/sr-ht-hook
+```
+
+POSTs a JSON payload to `url` with `Content-Type: application/json`. Headers:
+
+- `X-Payload-Nonce` — random 8-byte nonce, hex.
+- `X-Payload-Signature` — Ed25519 signature of the body. The public key is published per-instance; on `builds.sr.ht.org` it's at `https://meta.sr.ht/.well-known/webhook-pubkey` and equivalents.
+
+Body shape (matches the `JobStatus` struct in `worker/triggers.go`):
+
+```json
+{
+ "id": 1234567,
+ "status": "success",
+ "setup_log": "http://runner.example/logs/1234567/log",
+ "note": "abc1234 — Author Name\n\n Commit subject\n\n...",
+ "runner": "runner.example",
+ "owner": { "canonical_name": "~user", "name": "user" },
+ "tasks": [
+ { "name": "build", "status": "success",
+ "log": "http://runner.example/logs/1234567/build/log" }
+ ]
+}
+```
+
+Client timeout is 10 seconds. The receiver's response body is read up to 2 KiB and copied into the build log, which is handy for debugging the receiver.
+
+### Substitutes for `irc` and `job`
+
+- **IRC notification**: point `action: webhook` at an HTTP-to-IRC bridge you run (matterbridge, ZNC webhook plugin, a custom 30-line script). builds.sr.ht has never owned IRC-client connection state, and rewriting the trigger to do so isn't on the roadmap.
+- **Chained deploy** ("deploy after tests pass"):
+ - **Same-job final task**: simplest — add a `deploy:` task after `test:`. Use `submitter.allow-refs` so it only runs on the right branch. Downside: `oauth:` is set once per job, so the deploy task sees the same broad scope.
+ - **Webhook → external submitter**: have a small service receive the `webhook` trigger, then call `hut builds submit deploy.yml` (or the `submit` GraphQL mutation) to launch the deploy with a narrow `oauth:`.
+ - **Job groups** (see below): submit both jobs with `execute: false`, group them with a shared completion trigger, then `startGroup`. This is what hub.sr.ht does for patchset auto-testing.
+
+## Job groups
+
+Job groups bundle multiple jobs with shared triggers that fire after every member finishes. Created via the GraphQL `createGroup` mutation:
+
+```graphql
+mutation {
+ createGroup(jobIds: [123, 124, 125], triggers: [
+ { type: EMAIL, condition: FAILURE, email: { to: "me@example.com" } }
+ ]) { id }
+}
+```
+
+The `TriggerInput` schema accepts `type: EMAIL` and `type: WEBHOOK`, but the worker (`worker/triggers.go:processJobGroupTriggers`) only dispatches `EMAIL` — the webhook branch is a `TODO` comment. So group-level webhook triggers will log "Unknown trigger action" today; use per-job webhooks if you need that.
+
+Typical setup:
+
+1. Submit each job with `submit(manifest: ..., execute: false)` → returns pending job IDs.
+2. `createGroup(jobIds: [...], triggers: [...], execute: false)` → returns the group ID.
+3. `startGroup(groupId: ...)` → all jobs start.
+
+Practical uses:
+
+- Fan-out across architectures, get one summary email when the whole matrix finishes.
+- hub.sr.ht's patchset auto-tester: it groups all per-manifest jobs for a single patchset and attaches an `EMAIL` trigger that replies to the patchset's email thread with the aggregate result.
+
+There's no manifest-level "this job is in a group" directive. Groups are formed by the API caller (often `hut`, hub.sr.ht, or your own script). A job can only belong to one group, and once `startGroup` runs the group is immutable.
+
+## Tagging builds
+
+When submitting via API, you can pass `tags`:
+
+```graphql
+mutation {
+ submit(manifest: "...", tags: ["nightly", "release-candidate"]) { id }
+}
+```
+
+Tags become:
+- Filters in the dashboard: `builds.sr.ht/~user/?search=tag:nightly`
+- Status badges: a per-tag badge URL on the dashboard page
+
+git.sr.ht-submitted builds don't auto-tag, but you can submit manually-tagged builds via `hut`:
+
+```bash
+hut builds submit --tags nightly,release-candidate .build.yml
+```
+
+## Things that look like features but aren't
+
+**Required status checks**: there's no concept of "this build must pass before merge". SourceHut's contribution model is via patches on a mailing list, not PR merges. Build results post back to the patch thread; reviewers see them inline.
+
+**Status badges in the GitHub sense**: there are badge images, but they're per-tag, not per-branch. Use a release tag to badge a stable status.
+
+**Workflow dispatch / manual builds**: the closest equivalent is the web submit form. There's no "click to re-run with parameters" UI on the job page (you can resubmit a job, but the manifest stays identical).
+
+**Caching between builds**: no built-in cache. Workarounds: rsync to a cache server you control, or commit caches to a separate repo.
+
+**Concurrent build limits**: not exposed as a user-facing limit. Free accounts share worker capacity with everyone; if you have an unusual workload, the sourcehut admins email you about it (see the rizinorg/rizin GitHub issue for an example of what that conversation looks like).
A skills/sourcehut-ci/references/manifest.md => skills/sourcehut-ci/references/manifest.md +362 -0
@@ 0,0 1,362 @@
+# Build Manifest Reference
+
+Complete field reference for `.build.yml`. The canonical source is <https://man.sr.ht/builds.sr.ht/manifest.md>; this file is a working summary with usage notes.
+
+## Top-level structure
+
+```yaml
+image: alpine/edge # required
+arch: x86_64 # optional, defaults to x86_64
+packages: # optional
+ - pkg1
+repositories: # optional
+ reponame: "<url-or-line>"
+sources: # optional
+ - <url>[#ref]
+environment: # optional
+ KEY: value
+secrets: # optional
+ - <uuid>
+oauth: "<service>/<scope> ..." # optional
+shell: false # optional, default false
+artifacts: # optional
+ - path/to/file
+tasks: # required, at least one
+ - taskname: |
+ script
+triggers: # optional
+ - action: email|webhook # only these two are implemented
+ condition: success|failure|always
+ ...
+submitter: # optional, controls integration-side submission
+ git.sr.ht:
+ enabled: true
+ allow-refs: [...]
+```
+
+## `image` (required, string)
+
+Names the base VM image. Format is `<distro>/<release>`, or just `<distro>` for single-recipe images. The canonical support matrix is at <https://man.sr.ht/builds.sr.ht/compatibility.md>; values listed there as of 2026:
+
+- `alpine/edge` (rolling, daily), `alpine/latest` (= 3.23), `alpine/3.23`, `alpine/old` (= 3.22), `alpine/older` (= 3.21), `alpine/oldest` (= 3.20)
+- `archlinux`
+- `debian/stable` (= trixie / 13), `debian/oldstable` (= bookworm / 12), `debian/testing` (= forky), `debian/unstable` (= sid)
+- `ubuntu/lts` (= noble / 24.04), `ubuntu/oldlts` (= jammy / 22.04), `ubuntu/focal` (20.04), `ubuntu/oracular` (24.10), `ubuntu/plucky` (25.04)
+- `fedora/rawhide` (= 44), `fedora/latest` (= 43), `fedora/42`
+- `rockylinux/latest` (= 8), `rockylinux/9`
+- `freebsd/current` (= 16.0-CURRENT), `freebsd/latest` (= 15.x), `freebsd/14.x`
+- `openbsd/latest` (= 7.8), `openbsd/old` (= 7.7)
+- `netbsd/latest` (= 10.x), `netbsd/9.x`
+- `nixos/unstable`, `nixos/latest` (= 25.05), `nixos/24.11`
+- `guix`
+
+**`gentoo` and `9front` are not in upstream's published matrix or in the `builds.sr.ht/images/` recipe directory.** Submissions targeting them will fail with "no such image" on the upstream `builds.sr.ht.org` and almost all self-hosted instances. Don't suggest them by default.
+
+**Choosing**: Default to `alpine/edge` (small, fast boot, recent packages). Pick a specific distro when packaging for that distro or when a package is only available there. `archlinux` is good for AUR / rolling. `debian/stable` for Debian packaging. NixOS images are best for reproducible builds (flakes are off by default — enable via `NIX_CONFIG`).
+
+`image-name/latest` rolls forward over time; pin to a specific release if reproducibility matters.
+
+## `arch` (optional, string)
+
+CPU architecture. Default is whatever the image's recipe declares — `x86_64` for Alpine/Arch/Fedora/NixOS/Guix/Rocky, `amd64` for Debian/Ubuntu/FreeBSD/NetBSD/OpenBSD (BSDs and Debian-family use their upstream naming). The compatibility matrix at <https://man.sr.ht/builds.sr.ht/compatibility.md> shows the default arch per image with the table-primary row.
+
+Other arches (aarch64/arm64, ppc64el, riscv64, …) are listed for each image but only the marked rows are actually built on upstream `builds.sr.ht.org`. On a self-hosted instance, only arches whose qcow2 has been built with `genimg` will work. If submission fails with "no such image", the combination isn't built.
+
+## `packages` (optional, list of strings)
+
+Packages to install before tasks run, using the image's native package manager:
+
+- Alpine: `apk add` — names match `pkgs.alpinelinux.org`
+- Arch: `yay -Syu` (AUR packages are transparently installed) — names match `archlinux.org/packages` and the AUR
+- Debian/Ubuntu: `apt-get install` — names match `packages.debian.org` / `packages.ubuntu.com`
+- Fedora / Rocky: `dnf install`
+- FreeBSD: `pkg install` — names match FreeBSD ports
+- NetBSD: `pkgin`
+- OpenBSD: `pkg_add`
+- NixOS: `nix-env -iA` — requires the full selection path (e.g. `nixos.hello`), not bare names
+- Guix: `guix install`
+
+Cross-distro package names differ. There is no abstraction layer. Ninja is `ninja` on Alpine/Arch/Fedora/FreeBSD but `ninja-build` on Debian/Ubuntu. Look it up; don't guess.
+
+## `repositories` (optional, dict)
+
+Adds extra package repositories before installing `packages`. The value format is image-specific (`buildsrht/manifest.py` only validates that each value is a string); the worker hands `name` + `value` to the image's `add-repo` control script. Examples per the upstream compatibility matrix:
+
+```yaml
+# Alpine — value is "repo-url key-url key-name" (space-separated).
+# Prefix the *name* with @ if you want apk's @tag indirection.
+repositories:
+ sr.ht: >
+ https://mirror.sr.ht/alpine/sr.ht/
+ https://mirror.sr.ht/alpine/sr.ht/alpine%40sr.ht.rsa.pub
+ alpine@sr.ht.rsa.pub
+```
+
+```yaml
+# Debian/Ubuntu — value is "url release component key-id" (space-separated, key-id optional).
+repositories:
+ sr.ht: https://mirror.sr.ht/debian/sr.ht/ bookworm main DEADBEEFCAFEF00D
+```
+
+```yaml
+# Arch — value is "url#key-id".
+repositories:
+ myrepo: https://mirror.example.org/repo/$arch#DEADBEEFCAFEF00D
+```
+
+```yaml
+# Fedora / Rocky — value is a single URL; the worker calls `dnf config-manager` against it.
+repositories:
+ example: https://example.org/example.repo
+```
+
+```yaml
+# NixOS — value is a channel URL; `nix-channel --add <value> <name>` then `--update <name>`.
+repositories:
+ nixpkgs: https://nixos.org/channels/nixpkgs-unstable
+```
+
+Custom repositories are **not supported on FreeBSD, NetBSD, OpenBSD, or Guix** — the recipes ignore the directive on those images.
+
+Consult the compatibility page for the canonical syntax per image; sourcehut may add or refine formats over time.
+
+## `sources` (optional, list of strings)
+
+Repositories to clone into `/home/build` before tasks run. Each is cloned into a subdirectory named after the last URL component.
+
+```yaml
+sources:
+ - https://git.sr.ht/~user/projectA
+ - https://github.com/user/projectB
+ - https://git.example.org/projectC#some-tag
+ - hg+https://hg.sr.ht/~user/mercurial-project
+ - git+ssh://git@git.sr.ht/~user/private-repo
+ - mydir::https://git.example.org/user/upstream # clone into ./mydir
+ - "::https://git.example.org/upstream" # explicit "no rename"
+```
+
+- **Default SCM is git.** Prefix the URL scheme with `hg+` for Mercurial (`hg+https://`, `hg+ssh://`); the worker strips the prefix before invoking the underlying SCM.
+- **Pin a ref by appending `#<rev>`** — branch name, tag name, or commit SHA. For git, this becomes `git checkout`; for hg, `hg update`. Submodules are initialized (`git submodule update --init --recursive`) on git clones.
+- **Rename the local clone directory** with `dir::url`. By default the directory is the basename of the URL (with any `.git` stripped); `mytool::https://example.com/foo` clones into `./mytool` instead of `./foo`.
+- **Public repos** clone over HTTPS without credentials. **Private repos**: register an SSH-key secret and use the SSH form (`git@host:user/repo` or `git+ssh://git@host/user/repo`). The worker sets `GIT_SSH_COMMAND` to skip strict host-key checking, so you don't need `ssh-keyscan` for the clone (you do for arbitrary outbound SSH later in tasks).
+- **9front images** use `git9` instead of vanilla git, and the worker explicitly rejects SSH URLs there (no factotum support).
+- When git.sr.ht auto-submits the manifest, it rewrites the matching entry in `sources:` to the canonical clone URL plus `#<commit-sha>`. The build operates on the exact commit you pushed.
+
+## `environment` (optional, dict)
+
+Key/value pairs written to `~/.buildenv`, which is sourced by every task's preamble.
+
+```yaml
+environment:
+ CGO_ENABLED: 0
+ site: example.com
+ deploy_user: deploy@example.com
+```
+
+Values are strings. To pass state between tasks, append to `~/.buildenv` from inside a task:
+
+```yaml
+tasks:
+ - compute-version: |
+ VERSION=$(git -C myrepo describe --tags --always)
+ echo "VERSION=$VERSION" >> ~/.buildenv
+ - use-version: |
+ echo "Building version $VERSION"
+```
+
+Important: tasks are *separate login sessions*. Environment variables `export`ed in one task do not persist to the next unless written to `~/.buildenv`.
+
+## `secrets` (optional, list of UUIDs or names)
+
+Each entry is either a UUID or the human-readable name (3–512 chars) of a secret registered at <https://meta.sr.ht/secrets>. See `secrets-and-oauth.md` for the full discussion.
+
+```yaml
+secrets:
+ - 12345678-1234-1234-1234-123456789abc # by UUID
+ - github-deploy-key # by name
+```
+
+Secrets are installed into the build VM before tasks run. Whether they're available depends on submission context — patches from mailing lists, untrusted PRs, and some other integration paths disable secrets automatically.
+
+## `oauth` (optional, string)
+
+Space-separated list of sourcehut OAuth scope grants. builds.sr.ht generates a fresh OAuth 2.0 bearer token with exactly these grants for this build, exports it as `$OAUTH2_TOKEN` in `~/.buildenv`, and also writes it to `/home/build/.config/hut/config` (with per-service origins from the running instance's config) so `hut` finds it without extra setup. Both die with the VM.
+
+```yaml
+oauth: pages.sr.ht/PAGES:RW lists.sr.ht/PROFILE:RO
+```
+
+Scope syntax: `<service>/<SCOPE>:<RO|RW>`. Common grants:
+
+- `pages.sr.ht/PAGES:RW` — publish to pages.sr.ht
+- `builds.sr.ht/JOBS:RW` — submit further builds (used by triggers that chain jobs)
+- `git.sr.ht/REPOSITORIES:RW` — push to git repos
+- `lists.sr.ht/PROFILE:RO` — list user info
+- `meta.sr.ht/PROFILE:RO` — read account profile
+
+`hut` reads `~/.config/hut/config` (which the worker pre-writes) — no extra config needed. `curl --oauth2-bearer "$OAUTH2_TOKEN"` works for non-`hut` callers. Full mechanism + scope-to-command map in `references/hut.md`.
+
+Use `oauth:` instead of `secrets:` whenever you're talking to a sourcehut service. It's safer (short-lived, narrowly-scoped, can't be exfiltrated for reuse) and simpler (no secret to register).
+
+## `shell` (optional, bool)
+
+If `true`, keeps the build VM alive after tasks finish, even on success, so you can SSH in. Default `false`. Use during iteration; remove before committing.
+
+## `artifacts` (optional, list of strings)
+
+Files to extract from the build VM after success. See `artifacts.md`.
+
+```yaml
+artifacts:
+ - mytool
+ - myproject/dist/release.tar.gz
+```
+
+- No globs, no shell expansion. Each path is literal.
+- Paths are relative to the build user's home directory (`/home/build` on Linux images; differs on BSDs — `/root` etc., as set by the image's `Homedir`), or absolute. The worker resolves relative paths against `$HOME`, not the project clone directory.
+- Basenames must be unique across the list (the manifest parser rejects duplicates) — `dist/foo` and `bin/foo` cannot both be listed; rename first.
+- Only single files; directories must be tarred first.
+- Hard cap of **8 artifacts per build**; extras are silently dropped with a warning. Hard cap of **1 GiB per file**; oversize files fail the upload step.
+- Only uploaded on successful builds.
+- Pruned after 90 days.
+
+## `tasks` (required, list of one-key dicts)
+
+Each task is a `name: script` pair. The order matters; tasks run sequentially in fresh login sessions.
+
+```yaml
+tasks:
+ - configure: |
+ cd myproject
+ ./configure
+ - build: |
+ cd myproject
+ make
+ - test: |
+ cd myproject
+ make check
+```
+
+- Task names: lowercase alphanumeric, `_`, `-`. Max 128 chars.
+- Each task gets a section in the log.
+- A task fails the build if its script exits non-zero (the preamble has `set -e`).
+- Use `complete-build` inside a task to end the *whole build* successfully without running subsequent tasks.
+
+The list-of-dicts form (each task is a dict with one key) is canonical. Older docs occasionally showed a list of strings; use the dict form. YAML's `|` block-scalar form is what most manifests use because tasks are usually multi-line.
+
+## `triggers` (optional, list of dicts)
+
+Post-build actions. Each trigger has an `action` and a `condition`. The current `builds.sr.ht/worker` only implements **two** actions: `email` and `webhook`. The Python manifest parser (`buildsrht/manifest.py`) accepts only those two `TriggerAction` enum values, and the Go worker's dispatch map (`worker/triggers.go`) has entries for only those two — unrecognized actions log `Unknown trigger action` and are skipped.
+
+Older docs, blog posts, and out-of-date AI completions mention `irc` and `job` triggers. **They do not exist** in current builds.sr.ht; do not generate them.
+
+```yaml
+triggers:
+ - action: email
+ condition: failure
+ to: "Me <me@example.com>"
+ cc: "Other <them@example.com>"
+ in_reply_to: "<message-id@example.com>" # optional; used by hub.sr.ht patchset emails
+ - action: webhook
+ condition: always
+ url: https://example.com/sr-ht-hook
+```
+
+- `condition`: `success`, `failure`, or `always`. Internally maps `failed`/`timeout`/`cancelled` → "failure" and `success` → "success".
+- `email`: requires `to`. Optional `cc`, `in_reply_to`. Subject is `[<tags>] build <status>`; body lists task results with ✓/✗ glyphs. The sender comes from instance config (`builds.sr.ht::worker/trigger-from`).
+- `webhook`: HTTP POSTs a signed JSON payload (status, task list, owner, runner) to `url`. Headers `X-Payload-Nonce` and `X-Payload-Signature` are set so the receiver can verify authenticity. The receiver has 10 seconds to respond; the body is read up to 2 KiB into the build log.
+
+**Substitutes for the non-existent actions**:
+
+- `irc` → run an HTTP-to-IRC bridge on your own infrastructure, target it with `action: webhook`. Or write to a Matrix/Discord/IRC bridge service that accepts incoming webhooks (e.g. matterbridge, ZNC's web hook plugin).
+- `job` (chained deploy) → either inline the deploy as a later task in the same manifest (gated by `submitter.allow-refs` so it runs only on the right branch), or have your webhook receiver call `hut builds submit` / the `submit` GraphQL mutation.
+
+### Job group triggers
+
+When jobs are bundled into a job group via the GraphQL `createGroup` mutation, the API accepts `TriggerInput` records with `type: EMAIL` or `type: WEBHOOK`. Only `EMAIL` is wired up in the worker; `WEBHOOK` is a `TODO` (`// ctx.processGroupWebhook, TODO` in `worker/triggers.go`) and will be logged as "Unknown trigger action" if you set it today. hub.sr.ht's patchset auto-tester relies on this — it creates a per-patchset group with an EMAIL trigger that posts back to the mailing list thread.
+
+## `submitter` (optional, dict)
+
+Controls how integrations decide whether to submit this manifest on a given event. Only `git.sr.ht` is recognized by the git.sr.ht push hook today; the schema is:
+
+```yaml
+submitter:
+ git.sr.ht:
+ enabled: true # optional, default true
+ allow-refs: # optional, no filtering if absent
+ - refs/heads/master
+ - refs/heads/main
+ - "refs/tags/*"
+```
+
+Semantics, as implemented in git.sr.ht's `update-hook/submitter.go`:
+
+- **`enabled`** is tri-state. If unset or `true`, the block is permissive. If `false`, the build is skipped immediately and `allow-refs` is **not** consulted. There is no reason to write `enabled: true` explicitly — it changes nothing.
+- **`allow-refs`** is a list of `fnmatch(3)`-style glob patterns matched against the full ref (e.g. `refs/heads/master`, `refs/tags/v1.0`). The match runs with no flags, so `*` does cross `/` (i.e. `refs/heads/*` matches feature branches with slashes in them too, unlike a pathname-style glob). An empty/absent list means "no filtering"; a non-empty list means "submit only if at least one pattern matches".
+- The `git.sr.ht` key matches the integration that sees the manifest. A `hub.sr.ht:` block (used for patchset auto-testing) is parsed by hub.sr.ht's own code, not by this hook — and the git.sr.ht hook silently drops unknown keys when it re-serializes the manifest to send to builds.sr.ht. So put a `hub.sr.ht:` submitter rule in a `.build.yml` only if you understand that it survives only the patchset path, not the git-push path.
+- The filter runs **after** the manifest is located in the tree but **before** the GraphQL submit mutation fires. Non-matching refs never create a job, so this is strictly cheaper than `complete-build`-based filtering inside a task (which would still cost a VM boot).
+- Only auto-submission honors `submitter:`. Manual submission via `hut builds submit`, the web submit form, or a direct GraphQL `submit` mutation ignores the block — the runner doesn't re-evaluate it.
+
+This is the right way to limit which branches trigger CI. Filtering inside the script with `complete-build` works, but it wastes a worker slot to spin up a VM just to bail; `submitter:` prevents the job from being created at all.
+
+## `repositories` for non-package mirrors
+
+The `repositories:` directive is for *package* repos. To clone additional source repos, use `sources:`.
+
+## Built-in environment variables
+
+Always set by the worker before tasks run (see `builds.sr.ht/worker/tasks.go SendEnv`):
+
+- `CI_NAME` — literally `sourcehut`. Useful in scripts that fan out across CI systems.
+- `JOB_ID` — the integer job ID.
+- `JOB_URL` — full URL to the job page, e.g. `https://builds.sr.ht/~user/job/12345`.
+- `OAUTH2_TOKEN` — only set when the manifest has an `oauth:` directive **and** secrets are enabled for this submission. Bearer-token form, ready to pass to `hut` or `curl --oauth2-bearer`.
+
+Injected by integrations into the manifest's `environment:` block before submission, so they're available like any normal env variable:
+
+- **git.sr.ht push hook** (`update-hook/submitter.go`):
+ - `BUILD_SUBMITTER=git.sr.ht`
+ - `GIT_REF=<pushed-ref>` (e.g. `refs/heads/master`, `refs/tags/v1.0`)
+ - There is **no** `BUILD_REASON` set by git.sr.ht — only hub.sr.ht sets that.
+- **hub.sr.ht patchset auto-tester** (`hubsrht/builds.py`):
+ - `BUILD_SUBMITTER=hub.sr.ht`
+ - `BUILD_REASON=patchset`
+ - `PATCHSET_ID=<id>`
+ - `PATCHSET_URL=<url>`
+ - These are set with `setdefault`, so the build manifest may override them.
+- **Manual submission** (web form, `hut builds submit`, GraphQL `submit`): neither `BUILD_SUBMITTER` nor `BUILD_REASON` is set — only the always-set worker variables.
+
+The hook also rewrites `sources:` before submission. For each entry whose basename matches the pushed repo name, it replaces the URL with the canonical clone URL plus `#<commit-sha>`. If no entry matches the repo name, the clone URL is *appended* to `sources:`. The clone URL is the SSH form (`git+ssh://git@<host>/~<user>/<repo>`) for private repos and the HTTPS form for public repos. The commit-sha pin is what makes auto-submitted builds reproducible: the build operates on the exact commit you pushed, not on whatever the ref points to by the time the VM boots.
+
+`shell: true` is stripped from the manifest with a `Notice: removing 'shell: true' from build manifest` log line. Use `shell: true` only for manual submissions (web form, `hut builds submit`) where you want to SSH in regardless of outcome.
+
+Set when submitted via patchset (mailing list):
+
+- `PATCHSET_ID`, `PATCHSET_URL`
+
+Use these for conditional logic in tasks (mind the `[ -z "$VAR" ]` guard for cases where the build was submitted outside the corresponding integration):
+
+```yaml
+tasks:
+ - check: |
+ if [ -n "$GIT_REF" ] && [ "$GIT_REF" != "refs/heads/master" ]; then
+ complete-build
+ fi
+```
+
+## The `complete-build` command
+
+A magic in-VM command. Calling `complete-build` from any task immediately ends the build *successfully*, skipping all subsequent tasks. Use it for early exit when conditions aren't met (wrong branch, no relevant changes, etc.).
+
+It's technically undocumented but stable in practice — the sourcehut admins point users at it in support threads. Don't rely on it for security gating (it's a convenience, not an isolation primitive), and prefer `submitter.allow-refs` for ref filtering when the filter is known at submission time.
+
+## Gotchas
+
+- **`packages:` runs *before* tasks**, so package failures fail the build before any of your scripts run.
+- **`sources:` runs after `packages:`**, so you can use git from packages if needed.
+- **`set -xe` is on by default**, including for the `set +x` you might add. Re-enable with `set -x` if you want logging back.
+- **Multi-line YAML strings need `|`**, not `>` (which folds newlines). Almost all task scripts want `|`.
+- **`cd` doesn't persist across tasks** — they're separate sessions. `cd myproject` at the start of each task that needs it is normal and expected.
+- **The build user is `build`** with `/home/build` as home and passwordless sudo. You can install additional packages mid-build with sudo, but prefer the manifest's `packages:` list.
+- **Manifest size**: no explicit limit in the validator; it's bounded only by what the GraphQL submit endpoint accepts. For very large multi-line scripts, put the script in the repo and call it from the task — much easier to read and review than a wall of YAML.
+- **Job timeout** is set per-instance in `[builds.sr.ht::worker] timeout` (the upstream config example uses `45m`; `builds.sr.ht.org` runs longer; your self-hosted instance may differ). On timeout the job is marked `timeout` (treated as failure by triggers) and the VM is torn down. The post-failure SSH grace window is separate — 10 minutes if you don't log in, then "your remaining build time" once you do.
A skills/sourcehut-ci/references/pages.md => skills/sourcehut-ci/references/pages.md +379 -0
@@ 0,0 1,379 @@
+# pages.sr.ht Deployment
+
+pages.sr.ht hosts static websites. Each sourcehut user gets a subdomain at `username.srht.site` for free, plus unlimited custom domains. TLS is provisioned automatically.
+
+## The core publish workflow
+
+```yaml
+image: alpine/edge
+packages:
+ - hut
+oauth: pages.sr.ht/PAGES:RW
+environment:
+ site: username.srht.site
+sources:
+ - https://git.sr.ht/~username/my-site
+tasks:
+ - build: |
+ cd my-site
+ # whatever produces the static output
+ tar -czf ../site.tar.gz -C public .
+ - publish: |
+ hut pages publish -d "$site" site.tar.gz
+```
+
+Three things to get right:
+
+1. **`oauth: pages.sr.ht/PAGES:RW`** — gives the build a short-lived OAuth token with publish permission. The worker pre-writes `~/.config/hut/config` with the token, so `hut` Just Works (it does **not** read `$OAUTH2_TOKEN`; the env var is also set, but it's for `curl --oauth2-bearer`). Do *not* use a personal access token as a secret for this — it's worse in every way.
+2. **The tarball must contain only files at the top level**, not a wrapping directory. `tar -C <output-dir> .` does this. If you tar `public/` instead, you'll get a directory listing at `/public/` on the site instead of an index page.
+3. **`hut pages publish -d <domain>`** — `-d` takes the fully qualified domain, exactly as configured. Domains are case-sensitive in some edge cases; stick to lowercase.
+
+## Tarball requirements
+
+pages.sr.ht is strict about what it accepts:
+
+- **Format**: `.tar.gz` (gzipped tar). Plain `.tar`, `.tar.bz2`, `.tar.zst`, `.zip` are not accepted.
+- **Only directories and regular files**, mode 644. Symlinks are rejected.
+- **No executables**: file mode must be 644. Don't `chmod +x` anything before tarring.
+- **Files at the top level**: `index.html` at the tar's root, not nested in a directory.
+- **Size limit**: 1 GiB per site.
+- **Invalid data is silently discarded** (per the API docs). If your tarball has problems, pages.sr.ht may accept the publish call but serve nothing or stale content. When debugging, list the tarball: `tar -tzf site.tar.gz | head`.
+
+Common tar invocation patterns:
+
+```bash
+# Site files are in ./public/ (Hugo, Gatsby default, Eleventy default)
+tar -czf site.tar.gz -C public .
+
+# Site files are in ./_site/ (Jekyll default)
+tar -czf site.tar.gz -C _site .
+
+# Site files are in ./out/ (Next.js export default, Astro default for static)
+tar -czf site.tar.gz -C out .
+
+# Site files are in ./book/ (mdBook)
+tar -czf site.tar.gz -C book .
+
+# Already at the project root (raw HTML, no generator)
+tar -czf site.tar.gz .
+```
+
+## `hut pages publish` flags
+
+```
+hut pages publish -d <domain> [-s <subdirectory>] [--protocol https|gemini] [--site-config <path>] <tarball>
+```
+
+- `-d <domain>` — required. Fully qualified domain.
+- `-s <subdirectory>` — optional. Publish to a sub-path. Files at the root of the tarball end up at `https://domain/<subdirectory>/`. The rest of the site is preserved (this lets you update part of a site without re-uploading everything). Maps to the GraphQL `publish(subdirectory: ...)` argument.
+- `--protocol https|gemini` — defaults to `https`. Set to `gemini` for Gemini-protocol publishing (the same `pages.sr.ht` GraphQL endpoint serves both; the `Protocol` enum has `HTTPS` and `GEMINI`).
+- `--site-config <path>` — optional. Path to a JSON file controlling per-site options. Schema (all fields optional):
+
+ ```json
+ {
+ "notFound": "404.html",
+ "fileConfigs": [
+ { "glob": "*.png", "options": { "cacheControl": "max-age=15552000" } },
+ { "glob": "*.css", "options": { "cacheControl": "max-age=7200" } }
+ ]
+ }
+ ```
+
+ `notFound` is the path (inside the tarball) of the file to serve for 404s. `fileConfigs[].glob` is matched against the request path; `cacheControl` sets the response header verbatim. Maps to the GraphQL `publish(siteConfig: ...)` argument.
+
+## Custom domains
+
+DNS setup depends on whether it's an apex domain (`example.com`) or a subdomain (`www.example.com`, `docs.example.com`):
+
+### Apex domains — A + AAAA
+
+CNAME on the apex is not legal under RFC 1034 (and many registrars reject it). pages.sr.ht's solution is fixed IPs you point an A/AAAA record at:
+
+```
+example.com. IN A 46.23.81.157
+example.com. IN AAAA 2a03:6000:1813:1337::157
+```
+
+This is what the upstream `srht.site` docs publish. The IPs are stable but **not guaranteed forever** — sourcehut reserves the right to change them with 30 days' email notice to the affected accounts. Keep your meta.sr.ht email current.
+
+If your self-hosted pages instance (e.g. on `srht.bigb.es`) uses different IPs, check your instance's `pages.sr.ht/origin` or the operator's docs — the IPs above are only correct for the upstream `pages.sr.ht.` service.
+
+### Subdomains — CNAME
+
+```
+www.example.com. IN CNAME pages.sr.ht.
+docs.example.com. IN CNAME pages.sr.ht.
+```
+
+The trailing period is significant (it makes the target absolute). Some lousy registrars choke on it — drop it if they do; most still infer correctness.
+
+### Publishing
+
+After DNS resolves, publish to the domain by name:
+
+ ```bash
+ hut pages publish -d example.com site.tar.gz
+ ```
+
+3. **TLS is auto-provisioned** on first request. The first load may take a few seconds while a cert is obtained.
+
+**Multi-subdomain publishing**: `example.com` and `www.example.com` are *separate sites*. You must publish to both:
+
+```yaml
+tasks:
+ - publish-apex: |
+ hut pages publish -d example.com site.tar.gz
+ - publish-www: |
+ hut pages publish -d www.example.com site.tar.gz
+```
+
+There's no redirect-from-www feature; if you only want one canonical hostname, publish to the canonical one and don't configure DNS for the other.
+
+The `srht.site` recommendation is to skip `www.` entirely.
+
+## Static site generator recipes
+
+### Hugo
+
+```yaml
+image: alpine/edge
+packages:
+ - hugo
+ - hut
+oauth: pages.sr.ht/PAGES:RW
+environment:
+ site: username.srht.site
+sources:
+ - https://git.sr.ht/~username/my-site
+tasks:
+ - build: |
+ cd my-site
+ hugo --minify
+ tar -czf ../site.tar.gz -C public .
+ - publish: |
+ hut pages publish -d "$site" site.tar.gz
+```
+
+If your theme uses Dart Sass, Alpine's `hugo` package may not include the extended build. Either use the Hugo binary from upstream releases, or switch to `archlinux` where `hugo` is the extended build by default.
+
+### Zola
+
+```yaml
+image: alpine/edge
+packages:
+ - zola
+ - hut
+oauth: pages.sr.ht/PAGES:RW
+environment:
+ site: username.srht.site
+sources:
+ - https://git.sr.ht/~username/my-site
+tasks:
+ - build: |
+ cd my-site
+ zola build
+ tar -czf ../site.tar.gz -C public .
+ - publish: |
+ hut pages publish -d "$site" site.tar.gz
+```
+
+### Jekyll
+
+Jekyll wants Ruby; Alpine works but Ruby builds tend to be smaller/faster on Debian.
+
+```yaml
+image: debian/stable
+packages:
+ - ruby
+ - ruby-dev
+ - build-essential
+sources:
+ - https://git.sr.ht/~username/my-site
+oauth: pages.sr.ht/PAGES:RW
+environment:
+ site: username.srht.site
+tasks:
+ - install-hut: |
+ # hut isn't in Debian; build from source or download a release
+ curl -L https://git.sr.ht/~xenrox/hut/refs/download/v0.6.0/hut-0.6.0-linux-amd64 -o /tmp/hut
+ chmod +x /tmp/hut
+ sudo mv /tmp/hut /usr/local/bin/hut
+ - install-jekyll: |
+ sudo gem install jekyll bundler
+ - build: |
+ cd my-site
+ bundle install
+ bundle exec jekyll build
+ tar -czf ../site.tar.gz -C _site .
+ - publish: |
+ hut pages publish -d "$site" site.tar.gz
+```
+
+Check the hut release URL — the version pin should be current; `git.sr.ht/~xenrox/hut/refs` lists tags.
+
+### mdBook
+
+```yaml
+image: alpine/edge
+packages:
+ - cargo
+ - hut
+oauth: pages.sr.ht/PAGES:RW
+environment:
+ site: username.srht.site
+sources:
+ - https://git.sr.ht/~username/my-book
+tasks:
+ - install-mdbook: |
+ cargo install mdbook
+ echo 'export PATH=$HOME/.cargo/bin:$PATH' >> ~/.buildenv
+ - build: |
+ cd my-book
+ mdbook build
+ tar -czf ../site.tar.gz -C book .
+ - publish: |
+ hut pages publish -d "$site" site.tar.gz
+```
+
+`cargo install` puts binaries in `~/.cargo/bin`, which isn't on the default PATH in subsequent tasks. Appending to `~/.buildenv` makes it available in the next task.
+
+### Raw HTML / no generator
+
+```yaml
+image: alpine/edge
+packages:
+ - hut
+oauth: pages.sr.ht/PAGES:RW
+environment:
+ site: username.srht.site
+sources:
+ - https://git.sr.ht/~username/my-site
+tasks:
+ - publish: |
+ cd my-site
+ tar -czf ../site.tar.gz .
+ hut pages publish -d "$site" ../site.tar.gz
+```
+
+## Verifying the publish
+
+After `hut pages publish` succeeds, the site is live. The CLI prints the new version hash on success. To verify what's actually served:
+
+```bash
+curl -sI https://username.srht.site | head -n 3
+```
+
+If you've just configured a new custom domain, the first HTTPS request will take a few extra seconds while a TLS cert is provisioned.
+
+## Listing and removing sites
+
+```bash
+# List all your published sites
+hut pages list
+
+# Remove a site
+hut pages unpublish -d example.com
+```
+
+## Site limits and rules
+
+From the pages.sr.ht docs:
+
+- **1 GiB total storage per site, after decompression.** Hard limit enforced at publish time.
+- **Static content only** — no server-side execution, no PHP, no CGI.
+- **TLS only** (HTTPS); plain HTTP redirects to HTTPS. Certs are issued automatically per-domain.
+- **Content-Security-Policy is fixed** for every published site. Among its effects:
+ - Scripts and styles must be loaded from your own site, not a third-party CDN (`script-src 'self' 'unsafe-eval' 'unsafe-inline'`, `style-src 'self' 'unsafe-inline'`).
+ - Inline `<script>` and `<style>` work (because of `'unsafe-inline'` / `'unsafe-eval'`).
+ - Tracking scripts pointing at GA/Plausible/etc. won't load (no third-party `script-src`).
+ - Images, audio, and video may load over HTTPS from anywhere (`img-src https:`, `media-src https:`).
+ - Embedded iframes must use HTTPS.
+ - `object`/`embed` is blocked.
+
+ If you need to host third-party JS for your site to work, you have to host it yourself (copy the file into your tarball and reference it locally).
+- **Tarball contents**: directories and regular files of mode 644 only. Symlinks are silently dropped. Invalid tarballs are silently discarded.
+
+These are technical limits enforced by the publish endpoint and the CSP header, not just policies. Use a CDN if you need higher throughput or non-default CSP behavior.
+
+## Common failure modes
+
+**"Published, but site shows old content"**
+The tarball was probably malformed (symlinks, wrong permissions, wrapping directory). pages.sr.ht silently discards invalid uploads. `tar -tzvf site.tar.gz | head` to inspect; everything should be `-rw-r--r--` and at the top level.
+
+**"Published to apex, www doesn't work"**
+You need to publish twice — once per subdomain. There's no implicit www-handling.
+
+**TLS error on first visit**
+Cert provisioning is in progress. Wait a few seconds and retry. If it persists for more than a minute, check that DNS actually points at `pages.sr.ht`.
+
+**`hut pages publish` returns 401**
+The `oauth:` grant is wrong or missing. Confirm `oauth: pages.sr.ht/PAGES:RW` is in the manifest. `:RO` won't work for publishing — write access is required.
+
+**`hut: command not found`**
+`hut` isn't installed. Add `- hut` to `packages:`. On Debian/Ubuntu, hut isn't in the official repos — install from source or download a release binary as in the Jekyll example.
+
+**Site looks fine via curl but browser shows old version**
+Check `Cache-Control` headers. pages.sr.ht sets reasonable defaults but you may have a service worker, CDN, or browser cache holding old content.
+
+## Pattern: deploy on main branch only
+
+Combine `submitter.allow-refs` (prevents the build from being submitted at all on non-main branches) with the standard pattern:
+
+```yaml
+image: alpine/edge
+packages:
+ - hugo
+ - hut
+oauth: pages.sr.ht/PAGES:RW
+environment:
+ site: username.srht.site
+sources:
+ - https://git.sr.ht/~username/my-site
+submitter:
+ git.sr.ht:
+ enabled: true
+ allow-refs:
+ - refs/heads/master
+ - refs/heads/main
+tasks:
+ - build: |
+ cd my-site
+ hugo --minify
+ tar -czf ../site.tar.gz -C public .
+ - publish: |
+ hut pages publish -d "$site" site.tar.gz
+```
+
+## Pattern: preview + production from one repo
+
+Split into two manifests under `.builds/`:
+
+- `.builds/preview.yml` — publishes to `preview.example.com` on any push.
+- `.builds/production.yml` — publishes to `example.com`, gated to `refs/heads/main` via `submitter.allow-refs`.
+
+Each file has its own `submitter:` block; sourcehut handles them independently.
+
+## Pattern: regenerate-and-commit
+
+When the build generates content that should also live in the repo (e.g. a changelog or a search index), commit it back:
+
+```yaml
+image: alpine/edge
+packages:
+ - hut
+ - git-cliff
+secrets:
+ - <ssh-key-secret-uuid>
+sources:
+ - git@git.sr.ht:~username/my-site
+tasks:
+ - regenerate: |
+ cd my-site
+ git cliff -o CHANGELOG.md
+ git add CHANGELOG.md
+ if git diff --staged --quiet; then complete-build; fi
+ git -c user.name='builds.sr.ht' -c user.email='builds@sr.ht' commit -m 'Update CHANGELOG'
+ git push -o skip-ci origin HEAD:main
+```
+
+The `-o skip-ci` push option is **required** — without it, the commit would trigger another build, which would commit and push, in an infinite loop. The `git diff --staged --quiet` check avoids an empty commit when nothing changed.
A skills/sourcehut-ci/references/secrets-and-oauth.md => skills/sourcehut-ci/references/secrets-and-oauth.md +256 -0
@@ 0,0 1,256 @@
+# Secrets and OAuth
+
+builds.sr.ht has two distinct mechanisms for giving builds access to credentials, and choosing between them matters.
+
+## Quick guide
+
+| What you need | Use |
+|---|---|
+| Publish to pages.sr.ht | `oauth: pages.sr.ht/PAGES:RW` |
+| Submit further builds (chained jobs) | `oauth: builds.sr.ht/JOBS:RW` |
+| Push to a git.sr.ht repo (sourcehut-internal) | `oauth: git.sr.ht/REPOSITORIES:RW` |
+| Read sourcehut profile data | `oauth: meta.sr.ht/PROFILE:RO` |
+| Push to GitHub / GitLab / external SSH | SSH key as a **secret** |
+| Sign packages with PGP | PGP key as a **secret** |
+| Provide a config file with credentials | File **secret** |
+| Static API tokens for third-party services | File secret (containing the token) |
+
+**Rule of thumb**: anything talking to sourcehut → `oauth:`. Anything external → `secrets:`.
+
+## OAuth grants (`oauth:` directive)
+
+`oauth:` requests builds.sr.ht to mint a short-lived OAuth 2.0 token with specific scope grants for this build. The token is delivered two ways: exported as `$OAUTH2_TOKEN` in `~/.buildenv` (for `curl --oauth2-bearer` and friends), and written to `/home/build/.config/hut/config` as `access-token` along with per-service origins for the running instance (so `hut` can talk to the right hosts without per-job config). Both die with the VM. See `references/hut.md` for the exact config layout.
+
+```yaml
+oauth: pages.sr.ht/PAGES:RW builds.sr.ht/JOBS:RW
+```
+
+Format: space-separated `<service>/<SCOPE>:<RO|RW>` grants.
+
+**Why this is better than a stored personal access token**:
+
+1. **No setup**. No secret to register, no UUID to copy.
+2. **Narrow scope**. The token can only do what you grant; a leaked PAT could do everything your account can.
+3. **Short-lived**. Token expires with the build. A PAT lives until you remember to rotate it.
+4. **Auto-consumed**. `hut` finds its credentials at `~/.config/hut/config` (which the worker pre-writes); `curl --oauth2-bearer "$OAUTH2_TOKEN"` is the env-var path for everything else. No flag plumbing either way.
+
+Common scopes:
+
+- `pages.sr.ht/PAGES:RW` — publish/unpublish sites
+- `builds.sr.ht/JOBS:RW` — submit/cancel jobs
+- `builds.sr.ht/JOBS:RO` — read job status
+- `git.sr.ht/REPOSITORIES:RW` — create repos, push (limited; SSH is usually still needed for the actual git protocol push)
+- `lists.sr.ht/PROFILE:RO` — read mailing list info
+- `meta.sr.ht/PROFILE:RO` — read user profile / SSH keys / PGP keys
+- `paste.sr.ht/PASTES:RW` — create pastes
+- `todo.sr.ht/TICKETS:RW` — create/update tickets
+
+The scope catalog is at `https://meta.sr.ht/oauth2`. When in doubt, check there.
+
+**OAuth doesn't help with git push**. Even with `git.sr.ht/REPOSITORIES:RW`, the actual `git push` over SSH uses SSH keys, not the OAuth token. You still need an SSH-key secret for pushing back to a repo. The OAuth grant lets you call the GraphQL API (e.g. to create webhooks, update repo metadata).
+
+## Stored secrets (`secrets:` directive)
+
+Secrets are registered once on your account at `https://meta.sr.ht/secrets` and referenced in manifests by either UUID **or** the human-readable name you gave the secret. The Python validator (`buildsrht/manifest.py`) parses each entry as a UUID first; on failure it falls back to a 3–512 character string treated as a name lookup.
+
+```yaml
+secrets:
+ - 12345678-1234-1234-1234-123456789abc
+ - github-deploy-key # by name (3–512 chars)
+```
+
+UUID references are unambiguous across renames; name references are convenient when you copy a manifest between repos.
+
+builds.sr.ht installs each secret into the VM before tasks run. Three secret types:
+
+### SSH key secret
+
+When registered, paste the **private key** (e.g. contents of `~/.ssh/id_ed25519`). At build time, the worker writes it to `~/.ssh/<secret-uuid>` with mode 600. The **first** SSH-key secret in the manifest's `secrets:` list also gets symlinked to `~/.ssh/id_rsa`, which is what most tools look for by default — additional SSH-key secrets only show up under their UUID filenames, so you have to point ssh at them explicitly (`ssh -i ~/.ssh/<uuid>` or a `Host` block in `~/.ssh/config`).
+
+The worker does **not** populate `~/.ssh/known_hosts` — host-key checking is `StrictHostKeyChecking=no` for the initial `git clone` from `sources:` (set via `GIT_SSH_COMMAND`), but for any SSH you do inside tasks, `ssh-keyscan -H <host> >> ~/.ssh/known_hosts` first (or accept the StrictHostKey prompt would hang the build).
+
+Typical use: pushing to git remotes, rsync-deploying to a server, scp-uploading.
+
+```yaml
+image: alpine/edge
+packages: [openssh-client, rsync]
+secrets:
+ - <ssh-key-uuid>
+sources:
+ - https://git.sr.ht/~user/my-site
+tasks:
+ - deploy: |
+ ssh-keyscan -H deploy.example.com >> ~/.ssh/known_hosts
+ cd my-site
+ rsync -avz dist/ deploy@deploy.example.com:/var/www/
+```
+
+`ssh-keyscan -H` populates known_hosts so SSH doesn't prompt for host key acceptance.
+
+### PGP key secret
+
+When registered, paste an **ASCII-armored private key**. At build time, it's imported into the build user's GPG keyring.
+
+Typical use: signing release artifacts, signing Alpine/Arch packages, signing git tags.
+
+```yaml
+image: alpine/edge
+packages: [gnupg]
+secrets:
+ - <pgp-key-uuid>
+sources:
+ - https://git.sr.ht/~user/myproject
+tasks:
+ - sign: |
+ cd myproject
+ make release
+ gpg --batch --yes --detach-sign --armor dist/release.tar.gz
+artifacts:
+ - myproject/dist/release.tar.gz
+ - myproject/dist/release.tar.gz.asc
+```
+
+### File secret
+
+GraphQL type `SecretFile`. When registering, paste the **file contents** (binary OK) and specify the **destination path** (anywhere writable; the worker `mkdir -p`s the parent) and **mode** (octal). At build time, the file is written to that path with that mode.
+
+Typical use: API tokens, deploy keys for non-SSH-key services, config files with credentials, env-var files.
+
+```yaml
+secrets:
+ - <env-file-uuid> # registered to install at /home/build/.env, mode 600
+tasks:
+ - deploy: |
+ set +x # don't echo the env file load
+ . /home/build/.env
+ set -x
+ # use $AWS_ACCESS_KEY_ID, etc.
+```
+
+The `set +x` / `set -x` dance is important — without it, the `source` (or `.`) command echoes the file contents to the log, leaking the secret.
+
+## When secrets are disabled
+
+builds.sr.ht disables secret installation in some submission contexts to prevent untrusted code from exfiltrating credentials. From the schema docs on `submit()`: "secrets are enabled if at least one is specified in the manifest **and** the `SECRETS:RO` grant is available." Concretely:
+
+- **Patches submitted via mailing list**: hub.sr.ht's patchset auto-tester always passes `secrets: false` (the underlying mailing-list code path doesn't even request the `SECRETS:RO` grant).
+- **Explicit GraphQL submissions with `secrets: false`** in the `submit` mutation.
+- **The OAuth scope used to submit the build doesn't include `SECRETS:RO`** — even if you set `secrets: true`, the worker can't read them.
+- **The `oauth:` directive**: `$OAUTH2_TOKEN` is set only when `Job.Secrets && Manifest.OAuth != ""` (see `worker/tasks.go:oauth2Token`). Disabled secrets → no minted OAuth token either.
+
+Your manifest should degrade gracefully when secrets are absent — e.g. wrap deploy steps in a conditional:
+
+```yaml
+tasks:
+ - deploy: |
+ if [ ! -f ~/.ssh/id_rsa ]; then
+ echo "No deploy key, skipping deploy"
+ complete-build
+ fi
+ # actual deploy
+```
+
+## Secret security model
+
+You are trusted; the people who can read your repo are partially trusted; everyone else is untrusted.
+
+- Secrets are **per-account**, not per-repo. Any build you submit can request any of your secrets.
+- If you fork a repo and push, your account's secrets are available — not the original owner's.
+- For collaborative repos: collaborators with write access can edit `.build.yml`. They can include `secrets: [<your-uuid>]` and `cat ~/.ssh/id_rsa`, but the secret only gets installed if **the submitter** owns that secret. So a contributor pushing to your repo doesn't get your secrets — but your own pushes do install your secrets, including for `.build.yml` someone else may have modified. Review carefully.
+
+If a secret leaks to a public build log (e.g. `set -x` echoed it):
+
+- sourcehut policy is to **not redact** leaked secrets. Their reasoning: forcing you to actually rotate is the only way to be safe.
+- Treat the secret as compromised. Revoke at the source service. Generate a new one. Register the new secret with a new UUID.
+
+## Common patterns
+
+### Push to git.sr.ht from a build
+
+```yaml
+image: alpine/edge
+packages: [git, openssh-client]
+secrets:
+ - <ssh-key-uuid>
+sources:
+ - git@git.sr.ht:~user/my-repo # SSH form, not HTTPS
+tasks:
+ - work: |
+ ssh-keyscan -H git.sr.ht >> ~/.ssh/known_hosts
+ cd my-repo
+ # ... make changes ...
+ git -c user.name='builds.sr.ht' -c user.email='builds@sr.ht' commit -am 'Auto'
+ git push -o skip-ci origin HEAD:main
+```
+
+`-o skip-ci` is essential to avoid infinite build loops.
+
+### Push to GitHub from a sourcehut build
+
+```yaml
+image: alpine/edge
+packages: [git, openssh-client]
+secrets:
+ - <github-deploy-key-uuid>
+sources:
+ - https://git.sr.ht/~user/my-repo
+tasks:
+ - mirror: |
+ ssh-keyscan -H github.com >> ~/.ssh/known_hosts
+ cd my-repo
+ git push --force --mirror git@github.com:user/my-repo.git
+```
+
+### Authenticate to a sourcehut service without secrets
+
+```yaml
+image: alpine/edge
+packages: [hut]
+oauth: pages.sr.ht/PAGES:RW
+tasks:
+ - publish: |
+ hut pages publish -d example.com site.tar.gz
+```
+
+`hut` finds the minted token in `~/.config/hut/config`, which the worker pre-writes. No secret registration, no UUID, no rotation.
+
+### Manual curl call to a sourcehut GraphQL endpoint
+
+```yaml
+oauth: builds.sr.ht/JOBS:RO
+tasks:
+ - query: |
+ curl --oauth2-bearer "$OAUTH2_TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d '{"query":"{ me { username } }"}' \
+ https://builds.sr.ht/query
+```
+
+## Safe patterns for `set -x` and secrets
+
+Default preamble has `set -xe`. This echoes every command, including ones referencing secrets. Safe patterns:
+
+```bash
+# Disable trace before secret use, re-enable after
+set +x
+TOKEN=$(cat ~/.secrets/token)
+some_command --token "$TOKEN"
+set -x
+
+# Or: pass via stdin
+some_command --token-from-stdin <~/.secrets/token
+
+# Or: pass via env, set in a subshell where trace doesn't show the value
+( set +x; TOKEN=$(cat ~/.secrets/token); some_command )
+
+# Or: use OAuth and never touch a secret value yourself
+hut pages publish -d "$site" site.tar.gz # hut reads the worker-provisioned ~/.config/hut/config
+```
+
+The thing to avoid:
+
+```bash
+# BAD — set -x echoes the export line with the value
+export TOKEN="$(cat ~/.secrets/token)"
+# log will show: export TOKEN=actual-secret-value
+```