Stitch any CI into Tangled
151
fork

Configure Feed

Select the types of activity you want to include in your feed.

sourcehut: surface log fetch authorization failures

Previously the docs only required builds.sr.ht/JOBS:RW, but the
log endpoints under /query/log/<id>[/<task>]/log require
builds.sr.ht/LOGS:RO. A token with the documented scope would
submit jobs and poll status correctly, then 403 on every log fetch.
The provider's streamStep suppressed all non-404 errors at debug
level and emitted empty steps, so operators following the docs got
green builds whose logs were silently blank.

Update the docs to require both scopes and explain why. Add an
ErrUnauthorized sentinel to the sourcehut client and wrap 401/403
responses from GetTaskLog with it. In the provider, probe the
master log up front in Logs() so a systemic auth failure surfaces
as a Logs() error (5xx via the HTTP layer) rather than as an empty
stream. As defense in depth, streamStep now aborts the stream and
logs at error level on ErrUnauthorized instead of swallowing it.

+59 -3
+8 -3
docs/sourcehut.md
··· 13 13 | `TACK_SOURCEHUT_TOKEN` | Personal access token for builds.sr.ht (enables provider) | 14 14 | `TACK_SOURCEHUT_INSTANCE` | Base URL override (default `https://builds.sr.ht`) | 15 15 16 - Generate a token at `https://meta.sr.ht/oauth2/personal-token` with 17 - `builds.sr.ht/JOBS:RW` access, set the `TACK_SOURCEHUT_TOKEN=$token` env var, 18 - then start tack. 16 + Generate a token at `https://meta.sr.ht/oauth2/personal-token` with both 17 + `builds.sr.ht/JOBS:RW` and `builds.sr.ht/LOGS:RO` access, set the 18 + `TACK_SOURCEHUT_TOKEN=$token` env var, then start tack. 19 + 20 + `JOBS:RW` is required to submit jobs and poll their status. `LOGS:RO` 21 + is required because tack fetches per-task logs over an authenticated 22 + HTTP endpoint when serving the appview's log stream — without it the 23 + build will run to completion but every log request will fail. 19 24 20 25 ## Workflow YAML 21 26
+20
internal/sourcehut/sourcehut.go
··· 27 27 // for the /logs handler. 28 28 var ErrNotFound = errors.New("sourcehut: not found") 29 29 30 + // ErrUnauthorized is returned by Get* methods when the upstream rejects 31 + // the request with 401/403 — most commonly because the configured 32 + // personal access token is missing the requisite scope (e.g. the log 33 + // endpoints require `builds.sr.ht/LOGS:RO`). It's distinct from 34 + // ErrNotFound so callers can surface a systemic auth misconfiguration 35 + // instead of silently treating it as "no logs yet". 36 + var ErrUnauthorized = errors.New("sourcehut: unauthorized") 37 + 30 38 // Client is a thin wrapper around net/http carrying API credentials 31 39 // and the target instance base URL. Safe for concurrent use; the 32 40 // embedded http.Client is goroutine-safe. ··· 198 206 defer resp.Body.Close() 199 207 if resp.StatusCode == http.StatusNotFound { 200 208 return "", ErrNotFound 209 + } 210 + if resp.StatusCode == http.StatusUnauthorized || 211 + resp.StatusCode == http.StatusForbidden { 212 + // Surface auth failures distinctly so callers can stop the 213 + // log stream loudly rather than silently emitting empty 214 + // steps for every task. The most common cause is a token 215 + // missing `builds.sr.ht/LOGS:RO`. 216 + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 217 + return "", fmt.Errorf("get task log: status %d: %s: %w", 218 + resp.StatusCode, strings.TrimSpace(string(raw)), 219 + ErrUnauthorized, 220 + ) 201 221 } 202 222 if resp.StatusCode != http.StatusOK { 203 223 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+31
provider_sourcehut.go
··· 383 383 return nil, fmt.Errorf("get sourcehut job: %w", err) 384 384 } 385 385 386 + // Probe the master log up front to detect a systemic 387 + // authorization failure (typically a token that lacks 388 + // `builds.sr.ht/LOGS:RO`). Without this check, every per-task 389 + // fetch in the goroutine below would 403 and `streamStep` would 390 + // emit empty steps — leaving the operator with builds whose 391 + // status updates correctly but whose logs are silently blank. 392 + // ErrNotFound here is fine: the runner just hasn't produced any 393 + // setup output yet. 394 + if _, err := client.GetTaskLog(ctx, ref.JobID, ""); err != nil && 395 + !errors.Is(err, sourcehut.ErrNotFound) { 396 + if errors.Is(err, sourcehut.ErrUnauthorized) { 397 + return nil, fmt.Errorf( 398 + "sourcehut log fetch unauthorized; "+ 399 + "token likely missing `builds.sr.ht/LOGS:RO`: %w", err, 400 + ) 401 + } 402 + return nil, fmt.Errorf("probe sourcehut task log: %w", err) 403 + } 404 + 386 405 out := make(chan LogLine, 32) 387 406 go func() { 388 407 defer close(out) ··· 429 448 } 430 449 body, err := client.GetTaskLog(ctx, ref.JobID, logTask) 431 450 if err != nil && !errors.Is(err, sourcehut.ErrNotFound) { 451 + if errors.Is(err, sourcehut.ErrUnauthorized) { 452 + // Auth errors are systemic — every subsequent task fetch 453 + // will fail the same way. Log loudly and abort the stream 454 + // so the operator notices instead of getting a build with 455 + // every step rendered empty. The HTTP layer can't change 456 + // status mid-stream, but at least the server log will 457 + // point straight at the misconfigured token. 458 + p.log.Error("sourcehut log fetch unauthorized; aborting stream", 459 + "err", err, "job_id", ref.JobID, "task", logTask, 460 + ) 461 + return false 462 + } 432 463 // Don't fail the whole stream on one task; emit the end frame 433 464 // and move on so the renderer at least sees what other tasks 434 465 // produced. ErrNotFound (no log yet) is treated as an empty body.