~bigbes/sourcehut-root

ref: 72581ff94e21863373e2c72a01eb75332cb22f32 sourcehut-root/skills/sourcehut-ci/references/secrets-and-oauth.md -rw-r--r-- 11.1 KiB
72581ff9 — Eugene Blikh docs(sourcehut-ci): fix factual errors found in full audit 2 days ago

#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.

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://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:

#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/<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.

#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.

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 -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.

#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:

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

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

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

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

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:

# 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