builds.sr.ht has two distinct mechanisms for giving builds access to credentials, and choosing between them matters.
| 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: 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.
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:
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 sitesbuilds.sr.ht/JOBS:RW — submit/cancel jobsbuilds.sr.ht/JOBS:RO — read job statusgit.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 infometa.sr.ht/PROFILE:RO — read user profile / SSH keys / PGP keyspaste.sr.ht/PASTES:RW — create pastestodo.sr.ht/TICKETS:RW — create/update ticketsThe 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).
secrets: directive)Secrets are registered once on your account at https://builds.sr.ht/secrets (on builds.sr.ht, not meta.sr.ht — meta hosts OAuth clients and account SSH/PGP keys; build-secrets live on the service that consumes them) and referenced in manifests by either UUID or the human-readable name you gave the secret. hut builds secret is read-only (list/share); use the web UI or the GraphQL API to create them. 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.
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:
When registered, paste the private key (e.g. contents of ~/.ssh/id_ed25519). At build time, the worker writes it to ~/.ssh/<reference> with mode 600 — <reference> is whatever string you used in the manifest's secrets: list, so a UUID reference lands at ~/.ssh/<uuid> and a name reference lands at ~/.ssh/<name> (see builds.sr.ht/worker/tasks.go SendSecrets). The first SSH-key secret in the 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 <reference> filenames, so you have to point ssh at them explicitly (ssh -i ~/.ssh/<reference> 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.
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.
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.
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
GraphQL type SecretFile. When registering, paste the file contents (binary OK) and specify the destination path (anywhere writable; the worker mkdir -ps 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.
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.
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:
secrets: false (the underlying mailing-list code path doesn't even request the SECRETS:RO grant).secrets: false in the submit mutation.SECRETS:RO — even if you set secrets: true, the worker can't read them.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:
tasks:
- deploy: |
if [ ! -f ~/.ssh/id_rsa ]; then
echo "No deploy key, skipping deploy"
complete-build
fi
# actual deploy
You are trusted; the people who can read your repo are partially trusted; everyone else is untrusted.
.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):
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.
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
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.
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
set -x and secretsDefault preamble has set -xe. This echoes every command, including ones referencing secrets. Safe patterns:
# 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:
# BAD — set -x echoes the export line with the value
export TOKEN="$(cat ~/.secrets/token)"
# log will show: export TOKEN=actual-secret-value