From 10a4d6690e13796b4c37be3c3f4ec93a364cd803 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 21 May 2026 10:18:26 +0300 Subject: [PATCH] Add sourcehut-ci skill backup and justfile install target Mirror the live ~/.claude/skills/sourcehut-ci skill into skills/ and add an rsync-based 'install' recipe (repo -> ~/.claude). --- justfile | 12 + skills/sourcehut-ci/SKILL.md | 352 ++++++++++++++++ skills/sourcehut-ci/references/artifacts.md | 182 +++++++++ skills/sourcehut-ci/references/debugging.md | 212 ++++++++++ skills/sourcehut-ci/references/hut.md | 315 +++++++++++++++ skills/sourcehut-ci/references/images.md | 188 +++++++++ .../sourcehut-ci/references/integrations.md | 326 +++++++++++++++ skills/sourcehut-ci/references/manifest.md | 362 +++++++++++++++++ skills/sourcehut-ci/references/pages.md | 379 ++++++++++++++++++ .../references/secrets-and-oauth.md | 256 ++++++++++++ 10 files changed, 2584 insertions(+) create mode 100644 justfile create mode 100644 skills/sourcehut-ci/SKILL.md create mode 100644 skills/sourcehut-ci/references/artifacts.md create mode 100644 skills/sourcehut-ci/references/debugging.md create mode 100644 skills/sourcehut-ci/references/hut.md create mode 100644 skills/sourcehut-ci/references/images.md create mode 100644 skills/sourcehut-ci/references/integrations.md create mode 100644 skills/sourcehut-ci/references/manifest.md create mode 100644 skills/sourcehut-ci/references/pages.md create mode 100644 skills/sourcehut-ci/references/secrets-and-oauth.md diff --git a/justfile b/justfile new file mode 100644 index 0000000000000000000000000000000000000000..1bdd787e03a969cfb1eea36e64a629050d88d7e1 --- /dev/null +++ b/justfile @@ -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)" diff --git a/skills/sourcehut-ci/SKILL.md b/skills/sourcehut-ci/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..ad6fb140fac8718693cf8fb97782a4071ef3a6db --- /dev/null +++ b/skills/sourcehut-ci/SKILL.md @@ -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 ` 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 .` 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 `** — `-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=` 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 " + - 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 `` 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: ` 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 .` 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. diff --git a/skills/sourcehut-ci/references/artifacts.md b/skills/sourcehut-ci/references/artifacts.md new file mode 100644 index 0000000000000000000000000000000000000000..c4403d94f31f743e1fe3939efedef443189624ad --- /dev/null +++ b/skills/sourcehut-ci/references/artifacts.md @@ -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 " +``` + +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`). diff --git a/skills/sourcehut-ci/references/debugging.md b/skills/sourcehut-ci/references/debugging.md new file mode 100644 index 0000000000000000000000000000000000000000..d46eac22eb786687ffe4475fc51f39bc141d16d4 --- /dev/null +++ b/skills/sourcehut-ci/references/debugging.md @@ -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 ` — 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 >> ~/.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 `. 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/ +# 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. diff --git a/skills/sourcehut-ci/references/hut.md b/skills/sourcehut-ci/references/hut.md new file mode 100644 index 0000000000000000000000000000000000000000..4bafb225b0c95380f1b485d8118a548b78640e21 --- /dev/null +++ b/skills/sourcehut-ci/references/hut.md @@ -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 ""` (from the running sr.ht instance's `sr.ht/site-name` config — `default` if unset) + - `access-token ""` + - A ` { origin "" }` block for **every** `.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.` 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//...` 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 [-s ] [-p https|gemini] [--site-config ] +hut pages unpublish -d [-p https|gemini] +hut pages list +hut pages acl list -d +hut pages acl update [--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 . +hut pages publish -d "$site" site.tar.gz +``` + +`-s ` 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 ] [-s] [-t ] [-v public|unlisted|private] [manifest...] +hut builds resubmit [-e] [-f] [-n ] [-s] [-v ...] +hut builds list [owner] [--count N] [-s ] [-t ] +hut builds show [id] [-f] [--web] +hut builds artifacts # lists artifacts of a job +hut builds cancel +hut builds ssh # local-only, not useful in CI +hut builds update [-v ...] [-t ] +hut builds secret list [--count N] +hut builds secret share -u +``` + +`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 ` returns JSON-ish output with download URLs that require the same token; pair with `curl --oauth2-bearer "$OAUTH2_TOKEN" -L -o out.tar.gz ` 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 [--rev ] +hut git artifact list +hut git artifact delete +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 # writes patches into cwd +hut lists patchset update --status +hut lists patchset show +hut lists patchset list +``` + +Use case: a CI manifest triggered by a mailing-list webhook clones the repo, runs `hut lists patchset apply `, then `git am` / `git apply`, then runs tests. On failure, `hut lists patchset update --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 -s "" [-b ] +hut todo ticket update --status --resolution +hut todo ticket comment -b +hut todo ticket assign -u +hut todo ticket list -t +``` + +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 ] [-v public|unlisted|private] +hut paste list +hut paste delete +``` + +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 [-v key=value] [--stdin] [--file key=path] <.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: ) { 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: ` (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 `[.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. diff --git a/skills/sourcehut-ci/references/images.md b/skills/sourcehut-ci/references/images.md new file mode 100644 index 0000000000000000000000000000000000000000..ee105aaa297668674097a4d797efc71c23426330 --- /dev/null +++ b/skills/sourcehut-ci/references/images.md @@ -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///` 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////root.img.qcow2`. + +Submitting `image: /` 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 ` (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 " because rolling. +- Hugo is the extended (Sass-capable) build here, unlike Alpine. + +### Debian (`debian/`) + +- 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/`) + +- 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/`) + +- 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/`) + +- 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/`) + +- 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/`) + +- 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/`) + +- 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/`) + +- 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////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 (`//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-` 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: +- Arch: +- Debian: +- Ubuntu: +- Fedora: +- FreeBSD: + +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. diff --git a/skills/sourcehut-ci/references/integrations.md b/skills/sourcehut-ci/references/integrations.md new file mode 100644 index 0000000000000000000000000000000000000000..248853f5ae25456446171d65fce9ab01e493a22b --- /dev/null +++ b/skills/sourcehut-ci/references/integrations.md @@ -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 `#` of the pushed commit. If no entry matches, the clone URL is *appended* to `sources:`. Private repos get the SSH form (`git+ssh://git@/~/`); 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=` 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:///~//commit/ +[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