# 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 `/:` 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://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. ```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/` with mode 600 — `` is whatever string you used in the manifest's `secrets:` list, so a UUID reference lands at `~/.ssh/` and a name reference lands at `~/.ssh/` (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 `` filenames, so you have to point ssh at them explicitly (`ssh -i ~/.ssh/` 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 >> ~/.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: - 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: - 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: - # 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: []` 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: - 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: - 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 ```