Monorepo for Tangled
0

Configure Feed

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

spindle,lexicons: add sh.tangled.ci.describeWorkflowDefinition query

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

dawn (Jul 18, 2026, 5:59 PM +0300) ca962107 4cadfdc3

+273 -12
+46
api/tangled/cidescribeWorkflowDefinition.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.ci.describeWorkflowDefinition 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + CiDescribeWorkflowDefinitionNSID = "sh.tangled.ci.describeWorkflowDefinition" 15 + ) 16 + 17 + // CiDescribeWorkflowDefinition_Output is the output of a sh.tangled.ci.describeWorkflowDefinition call. 18 + type CiDescribeWorkflowDefinition_Output struct { 19 + // derived: Whether the workflow definition is derived from this repository at all. When false, no commit-to-commit comparison is meaningful (e.g. definitions managed externally), and callers should not surface change warnings. 20 + Derived bool `json:"derived" cborgen:"derived"` 21 + // hash: Opaque fingerprint of the workflow definition as resolved by the spindle; for git-derived spindles this covers the workflow files at the commit, not their post-compilation expansion. Only equality is defined: identical resolved definitions MUST produce identical hashes for a given spindle deployment, and differing definitions SHOULD produce differing hashes. Absent when derived is false. 22 + Hash *string `json:"hash,omitempty" cborgen:"hash,omitempty"` 23 + // workflows: Names or paths of the effective workflow files that produced the hash, for display purposes. 24 + Workflows []string `json:"workflows,omitempty" cborgen:"workflows,omitempty"` 25 + } 26 + 27 + // CiDescribeWorkflowDefinition calls the XRPC method "sh.tangled.ci.describeWorkflowDefinition". 28 + // 29 + // repo: Target repository DID the workflow definition belongs to. 30 + // sha: Commit SHA to resolve the workflow definition at 31 + // sourceRepo: Repository DID to resolve workflow definitions from, if different from the target repo (e.g. a fork for a fork-based pull request). 32 + func CiDescribeWorkflowDefinition(ctx context.Context, c util.LexClient, repo string, sha string, sourceRepo string) (*CiDescribeWorkflowDefinition_Output, error) { 33 + var out CiDescribeWorkflowDefinition_Output 34 + 35 + params := map[string]interface{}{} 36 + params["repo"] = repo 37 + params["sha"] = sha 38 + if sourceRepo != "" { 39 + params["sourceRepo"] = sourceRepo 40 + } 41 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.ci.describeWorkflowDefinition", params, nil, &out); err != nil { 42 + return nil, err 43 + } 44 + 45 + return &out, nil 46 + }
+62
lexicons/ci/describeWorkflowDefinition.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.ci.describeWorkflowDefinition", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Resolve the workflow definition a pipeline would use at a given commit and return a fingerprint of it.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repo", "sha"], 11 + "properties": { 12 + "repo": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "Target repository DID the workflow definition belongs to." 16 + }, 17 + "sha": { 18 + "type": "string", 19 + "minLength": 40, 20 + "maxLength": 40, 21 + "description": "Commit SHA to resolve the workflow definition at" 22 + }, 23 + "sourceRepo": { 24 + "type": "string", 25 + "format": "did", 26 + "description": "Repository DID to resolve workflow definitions from, if different from the target repo" 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["derived"], 35 + "properties": { 36 + "derived": { 37 + "type": "boolean", 38 + "description": "Whether the workflow definition is derived from this repository at all. When false, no commit-to-commit comparison is meaningful (e.g. definitions managed externally), and callers should not surface change warnings." 39 + }, 40 + "hash": { 41 + "type": "string", 42 + "description": "Fingerprint of the workflow definition as resolved by the spindle. Absent when derived is false." 43 + }, 44 + "workflows": { 45 + "type": "array", 46 + "items": { 47 + "type": "string" 48 + }, 49 + "description": "Names or paths of the effective workflow files that produced the hash." 50 + } 51 + } 52 + } 53 + }, 54 + "errors": [ 55 + { 56 + "name": "InvalidRequest", 57 + "description": "Invalid request parameters" 58 + } 59 + ] 60 + } 61 + } 62 + }
+34
spindle/fingerprint_test.go
··· 1 + package spindle 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + func TestFingerprintWorkflowDefinition(t *testing.T) { 12 + a := workflow.RawWorkflow{Name: "a.yml", Contents: []byte("engine: dummy\n")} 13 + b := workflow.RawWorkflow{Name: "b.yml", Contents: []byte("engine: nixery\n")} 14 + 15 + // iteration order must not affect the fingerprint 16 + assert.Equal(t, 17 + fingerprintWorkflowDefinition(workflow.RawPipeline{a, b}), 18 + fingerprintWorkflowDefinition(workflow.RawPipeline{b, a}), 19 + ) 20 + 21 + // content and name changes must both change the fingerprint 22 + base := fingerprintWorkflowDefinition(workflow.RawPipeline{a, b}) 23 + assert.NotEqual(t, base, fingerprintWorkflowDefinition(workflow.RawPipeline{ 24 + {Name: "a.yml", Contents: []byte("engine: nixery\n")}, b, 25 + })) 26 + assert.NotEqual(t, base, fingerprintWorkflowDefinition(workflow.RawPipeline{ 27 + {Name: "c.yml", Contents: a.Contents}, b, 28 + })) 29 + 30 + assert.NotEqual(t, 31 + fingerprintWorkflowDefinition(nil), 32 + fingerprintWorkflowDefinition(workflow.RawPipeline{a}), 33 + ) 34 + }
+74 -10
spindle/server.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "crypto/sha256" 5 6 "database/sql" 6 7 _ "embed" 8 + "encoding/binary" 7 9 "encoding/json" 8 10 "errors" 9 11 "fmt" ··· 11 13 "maps" 12 14 "net/http" 13 15 "path/filepath" 16 + "sort" 14 17 "sync" 15 18 "time" 16 19 ··· 698 701 } 699 702 } 700 703 701 - repoCloneUri := s.newRepoCloneUrl(repo.Knot, repoDid) 702 - repoPath := s.newRepoPath(repoDid) 703 - sourceInfo := triggerRepo // default: code comes from the repo itself 704 - if sourceRepo != "" && sourceRepo != repoDid { 705 - sourceInfo, err = s.resolveSourceRepoInfo(ctx, sourceRepo) 706 - if err != nil { 707 - return "", err 708 - } 704 + repoCloneUri, repoPath, sourceInfo, err := s.resolveCheckout(ctx, repoDid, sourceRepo) 705 + if err != nil { 706 + return "", err 707 + } 708 + if sourceInfo == nil { 709 + sourceInfo = triggerRepo 710 + } else { 709 711 sourceRepoStr := sourceRepo.String() 710 712 trigger.SourceRepo = &sourceRepoStr 711 - repoCloneUri = models.BuildRepoURL(sourceInfo) 712 - repoPath = s.newRepoPath(sourceRepo) 713 713 } 714 714 715 715 pipelineId, err := s.runPipeline(ctx, repoDid, trigger, nil, repoCloneUri, repoPath, sha, workflows, sourceInfo) ··· 720 720 return "", xrpc.ErrNoMatchingWorkflows 721 721 } 722 722 return pipelineId.AtUri(), nil 723 + } 724 + 725 + // sourceInfo is nil when the checkout comes from the target repo. 726 + func (s *Spindle) resolveCheckout(ctx context.Context, repoDid syntax.DID, sourceRepo syntax.DID) (cloneUri, repoPath string, sourceInfo *tangled.Pipeline_TriggerRepo, err error) { 727 + repo, err := s.db.GetRepoByDid(repoDid) 728 + if err != nil { 729 + return "", "", nil, fmt.Errorf("unknown repoDid %s: %w", repoDid, err) 730 + } 731 + 732 + cloneUri = s.newRepoCloneUrl(repo.Knot, repoDid) 733 + repoPath = s.newRepoPath(repoDid) 734 + if sourceRepo != "" && sourceRepo != repoDid { 735 + sourceInfo, err = s.resolveSourceRepoInfo(ctx, sourceRepo) 736 + if err != nil { 737 + return "", "", nil, err 738 + } 739 + cloneUri = models.BuildRepoURL(sourceInfo) 740 + repoPath = s.newRepoPath(sourceRepo) 741 + } 742 + return cloneUri, repoPath, sourceInfo, nil 743 + } 744 + 745 + // resolves the workflow definition at sha without executing it 746 + // returns a deterministic fingerprint over the resolved files. 747 + func (s *Spindle) DescribeWorkflowDefinition(ctx context.Context, repoDid syntax.DID, sha string, sourceRepo syntax.DID) (*tangled.CiDescribeWorkflowDefinition_Output, error) { 748 + repoCloneUri, repoPath, _, err := s.resolveCheckout(ctx, repoDid, sourceRepo) 749 + if err != nil { 750 + return nil, err 751 + } 752 + 753 + rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, sha) 754 + if err != nil { 755 + return nil, fmt.Errorf("loading pipeline: %w", err) 756 + } 757 + 758 + hash := fingerprintWorkflowDefinition(rawPipeline) 759 + workflows := make([]string, 0, len(rawPipeline)) 760 + for _, w := range rawPipeline { 761 + workflows = append(workflows, w.Name) 762 + } 763 + 764 + return &tangled.CiDescribeWorkflowDefinition_Output{ 765 + Derived: true, 766 + Hash: &hash, 767 + Workflows: workflows, 768 + }, nil 769 + } 770 + 771 + func fingerprintWorkflowDefinition(rawPipeline workflow.RawPipeline) string { 772 + sorted := make([]workflow.RawWorkflow, len(rawPipeline)) 773 + copy(sorted, rawPipeline) 774 + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) 775 + 776 + h := sha256.New() 777 + var lenBuf [8]byte 778 + for _, w := range sorted { 779 + binary.LittleEndian.PutUint64(lenBuf[:], uint64(len(w.Contents))) 780 + h.Write([]byte(w.Name)) 781 + // terminate name to avoid ["fo", "o"] == ["f", "oo"] 782 + h.Write([]byte{0}) 783 + h.Write(lenBuf[:]) 784 + h.Write(w.Contents) 785 + } 786 + return fmt.Sprintf("sha256:%x", h.Sum(nil)) 723 787 } 724 788 725 789 func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) {
+45
spindle/xrpc/ci_pipeline_describe_workflow_definition.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + xrpcerr "tangled.org/core/xrpc/errors" 7 + ) 8 + 9 + func (x *Xrpc) DescribeWorkflowDefinition(w http.ResponseWriter, r *http.Request) { 10 + l := x.Logger 11 + fail := func(e xrpcerr.XrpcError) { 12 + l.Error("failed", "kind", e.Tag, "error", e.Message) 13 + writeError(w, e, http.StatusBadRequest) 14 + } 15 + 16 + q := r.URL.Query() 17 + repoDid, xerr, ok := x.resolveKnownRepoDid(q.Get("repo")) 18 + if !ok { 19 + fail(xerr) 20 + return 21 + } 22 + 23 + sha := q.Get("sha") 24 + if err := requireSha(sha); err != nil { 25 + fail(xrpcerr.NewXrpcError(xrpcerr.WithTag("InvalidRequest"), xrpcerr.WithError(err))) 26 + return 27 + } 28 + 29 + sourceRepoParam := q.Get("sourceRepo") 30 + sourceRepo, err := parseOptionalDID("sourceRepo", &sourceRepoParam) 31 + if err != nil { 32 + fail(xrpcerr.NewXrpcError(xrpcerr.WithTag("InvalidRequest"), xrpcerr.WithError(err))) 33 + return 34 + } 35 + 36 + out, err := x.Trigger.DescribeWorkflowDefinition(r.Context(), repoDid, sha, sourceRepo) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + 42 + if err := writeJson(w, http.StatusOK, out); err != nil { 43 + l.Error("failed to write response", "err", err) 44 + } 45 + }
+2 -2
spindle/xrpc/ci_pipeline_trigger_pipeline.go
··· 105 105 return 106 106 } 107 107 108 - if len(sha) != 40 { 109 - fail(xrpcerr.GenericError(fmt.Errorf("sha must be a 40-character commit hash"))) 108 + if err := requireSha(sha); err != nil { 109 + fail(xrpcerr.GenericError(err)) 110 110 return 111 111 } 112 112
+10
spindle/xrpc/xrpc.go
··· 5 5 _ "embed" 6 6 "encoding/json" 7 7 "errors" 8 + "fmt" 8 9 "log/slog" 9 10 "net/http" 10 11 ··· 27 28 28 29 var ErrNoMatchingWorkflows = errors.New("no workflows to run") 29 30 31 + func requireSha(sha string) error { 32 + if len(sha) != 40 { 33 + return fmt.Errorf("sha must be a 40-character commit hash") 34 + } 35 + return nil 36 + } 37 + 30 38 // this is to break an import cycle. spindle imports this package for Xrpc, 31 39 // so this package can't import *spindle.Spindle back. 32 40 type PipelineTrigger interface { 33 41 TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) 42 + DescribeWorkflowDefinition(ctx context.Context, repoDid syntax.DID, sha string, sourceRepo syntax.DID) (*tangled.CiDescribeWorkflowDefinition_Output, error) 34 43 } 35 44 36 45 type PullContext struct { ··· 68 77 69 78 // service query endpoints (no auth required) 70 79 r.Get("/"+tangled.OwnerNSID, x.Owner) 80 + r.Get("/"+tangled.CiDescribeWorkflowDefinitionNSID, x.DescribeWorkflowDefinition) 71 81 r.Get("/"+tangled.CiSubscribePipelineLogsNSID, x.HandleCiSubscribePipelineLogs) 72 82 r.Get("/"+tangled.CiQueryPipelinesNSID, x.HandleCiQueryPipelines) 73 83 r.Get("/"+tangled.CiGetPipelineNSID, x.HandleCiGetPipeline)