Monorepo for Tangled
0

Configure Feed

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

spindle,knotserver: honor skip-ci and restore verbose-ci push options

this adds back skip-ci and verbose-ci, they were regressed in earlier spindle rewrite work

Signed-off-by: dawn <dawn@tangled.org>

authored by

dawn and committed by
Tangled
(Jul 10, 2026, 2:54 PM +0300) 16e267c7 44c4e605

+249 -6
+8
docs/DOCS.md
··· 843 843 tag: ["v*", "stable"] 844 844 ``` 845 845 846 + To skip CI for a push, pass a Git push option: 847 + 848 + ```sh 849 + git push -o skip-ci 850 + ``` 851 + 852 + `ci-skip` is also accepted. 853 + 846 854 ### Engine 847 855 848 856 Next is the engine on which the workflow should run, defined
+9
knotserver/git/post_receive.go
··· 171 171 }, 172 172 } 173 173 } 174 + func HasSkipCIPushOption(pushOptions []string) bool { 175 + for _, opt := range pushOptions { 176 + switch opt { 177 + case "skip-ci", "ci-skip": 178 + return true 179 + } 180 + } 181 + return false 182 + }
+115 -6
knotserver/internal.go
··· 28 28 "tangled.org/core/notifier" 29 29 "tangled.org/core/rbac" 30 30 "tangled.org/core/tid" 31 + "tangled.org/core/workflow" 31 32 ) 32 33 33 34 type InternalHandle struct { ··· 242 243 pushOptions = pushOptions[:50] 243 244 } 244 245 246 + repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 247 + if resolveErr != nil { 248 + l.Error("failed to resolve repo on disk", "repoDid", repoDid, "err", resolveErr) 249 + w.WriteHeader(http.StatusInternalServerError) 250 + return 251 + } 252 + 245 253 resp := hook.HookResponse{ 246 254 Messages: make([]string, 0), 247 255 } 248 256 249 257 for _, line := range lines { 250 - err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoDid, pushOptions) 258 + err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoDid, repoPath, pushOptions) 251 259 if err != nil { 252 260 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 253 261 } ··· 257 265 l.Error("failed to reply with pull request link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 258 266 } 259 267 268 + if !git.HasSkipCIPushOption(pushOptions) { 269 + verbose := hasVerboseCIPushOption(pushOptions) 270 + if err := h.emitCiDiagnostics(&resp.Messages, line, ownerDid, repoName, repoDid, repoPath, verbose); err != nil { 271 + l.Error("failed to emit ci diagnostics", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 272 + } 273 + } 274 + 260 275 // emit pipeline logs link 261 276 if h.c.LogsAddr != "" { 262 277 host, port, err := net.SplitHostPort(h.c.LogsAddr) ··· 270 285 writeJSON(w, resp) 271 286 } 272 287 273 - func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoDid string, pushOptions []string) error { 288 + func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoDid string, repoPath string, pushOptions []string) error { 274 289 refUpdate := tangled.GitRefUpdate{ 275 290 OldSha: line.OldSha.String(), 276 291 NewSha: line.NewSha.String(), ··· 283 298 } 284 299 285 300 if !line.NewSha.IsZero() { 286 - repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 287 - if resolveErr != nil { 288 - return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 289 - } 290 301 291 302 gr, err := git.Open(repoPath, line.Ref) 292 303 if err != nil { ··· 320 331 } 321 332 322 333 return h.db.InsertEvent(event, h.n) 334 + } 335 + 336 + func hasVerboseCIPushOption(pushOptions []string) bool { 337 + for _, opt := range pushOptions { 338 + switch opt { 339 + case "verbose-ci", "ci-verbose": 340 + return true 341 + } 342 + } 343 + return false 344 + } 345 + 346 + func (h *InternalHandle) emitCiDiagnostics( 347 + clientMsgs *[]string, 348 + line git.PostReceiveLine, 349 + ownerDid string, 350 + repoName string, 351 + repoDid string, 352 + repoPath string, 353 + verbose bool, 354 + ) error { 355 + if line.NewSha.IsZero() { 356 + return nil 357 + } 358 + 359 + gr, err := git.Open(repoPath, line.Ref) 360 + if err != nil { 361 + return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 362 + } 363 + 364 + workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 365 + if err != nil { 366 + return nil 367 + } 368 + 369 + var pipeline workflow.RawPipeline 370 + for _, e := range workflowDir { 371 + if !e.IsFile() { 372 + continue 373 + } 374 + 375 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 376 + contents, err := gr.RawContent(fpath) 377 + if err != nil { 378 + continue 379 + } 380 + 381 + pipeline = append(pipeline, workflow.RawWorkflow{ 382 + Name: e.Name, 383 + Contents: contents, 384 + }) 385 + } 386 + 387 + defaultBranch, _ := gr.FindMainBranch() 388 + 389 + trigger := tangled.Pipeline_PushTriggerData{ 390 + Ref: line.Ref, 391 + OldSha: line.OldSha.String(), 392 + NewSha: line.NewSha.String(), 393 + } 394 + 395 + triggerRepo := &tangled.Pipeline_TriggerRepo{ 396 + Did: ownerDid, 397 + Knot: h.c.Server.Hostname, 398 + Repo: &repoName, 399 + RepoDid: &repoDid, 400 + DefaultBranch: defaultBranch, 401 + } 402 + 403 + changedFiles, err := gr.ChangedFilesBetween(line.OldSha.String(), line.NewSha.String()) 404 + if err != nil { 405 + return fmt.Errorf("getting changed files: %w", err) 406 + } 407 + 408 + compiler := workflow.Compiler{ 409 + Trigger: tangled.Pipeline_TriggerMetadata{ 410 + Kind: string(workflow.TriggerKindPush), 411 + Push: &trigger, 412 + Repo: triggerRepo, 413 + }, 414 + ChangedFiles: changedFiles, 415 + } 416 + 417 + compiler.Compile(compiler.Parse(pipeline)) 418 + 419 + for _, e := range compiler.Diagnostics.Errors { 420 + *clientMsgs = append(*clientMsgs, e.String()) 421 + } 422 + if verbose { 423 + if compiler.Diagnostics.IsEmpty() { 424 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 425 + } 426 + for _, w := range compiler.Diagnostics.Warnings { 427 + *clientMsgs = append(*clientMsgs, w.String()) 428 + } 429 + } 430 + 431 + return nil 323 432 } 324 433 325 434 func (h *InternalHandle) emitPullRequestLink(
+57
knotserver/internal_pushoptions_test.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "testing" 5 + 6 + "tangled.org/core/knotserver/git" 7 + ) 8 + 9 + func TestHasVerboseCIPushOption(t *testing.T) { 10 + cases := []struct { 11 + name string 12 + pushOptions []string 13 + want bool 14 + }{ 15 + {"verbose-ci token", []string{"verbose-ci"}, true}, 16 + {"ci-verbose token", []string{"ci-verbose"}, true}, 17 + {"verbose token among others", []string{"foo", "ci-verbose", "bar"}, true}, 18 + {"skip-ci is not verbose", []string{"skip-ci"}, false}, 19 + {"ci-skip is not verbose", []string{"ci-skip"}, false}, 20 + {"unrelated token", []string{"whatever"}, false}, 21 + {"empty slice", []string{}, false}, 22 + {"nil slice", nil, false}, 23 + } 24 + 25 + for _, tc := range cases { 26 + t.Run(tc.name, func(t *testing.T) { 27 + if got := hasVerboseCIPushOption(tc.pushOptions); got != tc.want { 28 + t.Errorf("hasVerboseCIPushOption(%v) = %v, want %v", tc.pushOptions, got, tc.want) 29 + } 30 + }) 31 + } 32 + } 33 + 34 + func TestHasSkipCIPushOption(t *testing.T) { 35 + cases := []struct { 36 + name string 37 + pushOptions []string 38 + want bool 39 + }{ 40 + {"skip-ci token", []string{"skip-ci"}, true}, 41 + {"ci-skip token", []string{"ci-skip"}, true}, 42 + {"skip token among others", []string{"foo", "skip-ci", "bar"}, true}, 43 + {"verbose-ci is not skip", []string{"verbose-ci"}, false}, 44 + {"ci-verbose is not skip", []string{"ci-verbose"}, false}, 45 + {"unrelated token", []string{"whatever"}, false}, 46 + {"empty slice", []string{}, false}, 47 + {"nil slice", nil, false}, 48 + } 49 + 50 + for _, tc := range cases { 51 + t.Run(tc.name, func(t *testing.T) { 52 + if got := git.HasSkipCIPushOption(tc.pushOptions); got != tc.want { 53 + t.Errorf("hasSkipCIPushOption(%v) = %v, want %v", tc.pushOptions, got, tc.want) 54 + } 55 + }) 56 + } 57 + }
+5
spindle/server.go
··· 427 427 return fmt.Errorf("repo knot does not match event source: %s != %s", src.Host, repo.Knot) 428 428 } 429 429 430 + if kgit.HasSkipCIPushOption(event.PushOptions) { 431 + l.Info("push event requested ci skip, skipping the event") 432 + return nil 433 + } 434 + 430 435 // NOTE: we are blindly trusting the knot that it will return only repos it own 431 436 repoCloneUri := s.newRepoCloneUrl(src.Host, repoDid) 432 437 repoPath := s.newRepoPath(repoDid)
+55
spindle/server_test.go
··· 1 + package spindle 2 + 3 + import ( 4 + "testing" 5 + 6 + kgit "tangled.org/core/knotserver/git" 7 + ) 8 + 9 + func TestHasSkipCIPushOption(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + pushOptions []string 13 + want bool 14 + }{ 15 + { 16 + name: "skip-ci requests skip", 17 + pushOptions: []string{"skip-ci"}, 18 + want: true, 19 + }, 20 + { 21 + name: "ci-skip requests skip", 22 + pushOptions: []string{"ci-skip"}, 23 + want: true, 24 + }, 25 + { 26 + name: "unrelated ci options do not skip", 27 + pushOptions: []string{"verbose-ci", "ci-verbose"}, 28 + want: false, 29 + }, 30 + { 31 + name: "empty options do not skip", 32 + pushOptions: []string{}, 33 + want: false, 34 + }, 35 + { 36 + name: "nil options do not skip", 37 + pushOptions: nil, 38 + want: false, 39 + }, 40 + { 41 + name: "mixed options skip when any skip option appears", 42 + pushOptions: []string{"verbose-ci", "skip-ci", "ci-verbose"}, 43 + want: true, 44 + }, 45 + } 46 + 47 + for _, tt := range tests { 48 + t.Run(tt.name, func(t *testing.T) { 49 + got := kgit.HasSkipCIPushOption(tt.pushOptions) 50 + if got != tt.want { 51 + t.Fatalf("hasSkipCIPushOption(%v) = %v, want %v", tt.pushOptions, got, tt.want) 52 + } 53 + }) 54 + } 55 + }