···75117511 }
7512751275137513 cw := cbg.NewCborWriter(w)
75147514- fieldCount := 1
75147514+ fieldCount := 3
7515751575167516 if t.Inputs == nil {
75177517+ fieldCount--
75187518+ }
75197519+75207520+ if t.Ref == nil {
75177521 fieldCount--
75187522 }
75197523···75217525 return err
75227526 }
7523752775287528+ // t.Ref (string) (string)
75297529+ if t.Ref != nil {
75307530+75317531+ if len("ref") > 1000000 {
75327532+ return xerrors.Errorf("Value in field \"ref\" was too long")
75337533+ }
75347534+75357535+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("ref"))); err != nil {
75367536+ return err
75377537+ }
75387538+ if _, err := cw.WriteString(string("ref")); err != nil {
75397539+ return err
75407540+ }
75417541+75427542+ if t.Ref == nil {
75437543+ if _, err := cw.Write(cbg.CborNull); err != nil {
75447544+ return err
75457545+ }
75467546+ } else {
75477547+ if len(*t.Ref) > 1000000 {
75487548+ return xerrors.Errorf("Value in field t.Ref was too long")
75497549+ }
75507550+75517551+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Ref))); err != nil {
75527552+ return err
75537553+ }
75547554+ if _, err := cw.WriteString(string(*t.Ref)); err != nil {
75557555+ return err
75567556+ }
75577557+ }
75587558+ }
75597559+75607560+ // t.Sha (string) (string)
75617561+ if len("sha") > 1000000 {
75627562+ return xerrors.Errorf("Value in field \"sha\" was too long")
75637563+ }
75647564+75657565+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sha"))); err != nil {
75667566+ return err
75677567+ }
75687568+ if _, err := cw.WriteString(string("sha")); err != nil {
75697569+ return err
75707570+ }
75717571+75727572+ if len(t.Sha) > 1000000 {
75737573+ return xerrors.Errorf("Value in field t.Sha was too long")
75747574+ }
75757575+75767576+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Sha))); err != nil {
75777577+ return err
75787578+ }
75797579+ if _, err := cw.WriteString(string(t.Sha)); err != nil {
75807580+ return err
75817581+ }
75827582+75247583 // t.Inputs ([]*tangled.Pipeline_Pair) (slice)
75257584 if t.Inputs != nil {
75267585···75937652 }
7594765375957654 switch string(nameBuf[:nameLen]) {
75967596- // t.Inputs ([]*tangled.Pipeline_Pair) (slice)
76557655+ // t.Ref (string) (string)
76567656+ case "ref":
76577657+76587658+ {
76597659+ b, err := cr.ReadByte()
76607660+ if err != nil {
76617661+ return err
76627662+ }
76637663+ if b != cbg.CborNull[0] {
76647664+ if err := cr.UnreadByte(); err != nil {
76657665+ return err
76667666+ }
76677667+76687668+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
76697669+ if err != nil {
76707670+ return err
76717671+ }
76727672+76737673+ t.Ref = (*string)(&sval)
76747674+ }
76757675+ }
76767676+ // t.Sha (string) (string)
76777677+ case "sha":
76787678+76797679+ {
76807680+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
76817681+ if err != nil {
76827682+ return err
76837683+ }
76847684+76857685+ t.Sha = string(sval)
76867686+ }
76877687+ // t.Inputs ([]*tangled.Pipeline_Pair) (slice)
75977688 case "inputs":
7598768975997690 maj, extra, err = cr.ReadHeader()
+11-11
api/tangled/pipelinecancelPipeline.go
···2233package tangled
4455-// schema: sh.tangled.pipeline.cancelPipeline
55+// schema: sh.tangled.ci.pipeline.cancelPipeline
6677import (
88 "context"
···1111)
12121313const (
1414- PipelineCancelPipelineNSID = "sh.tangled.pipeline.cancelPipeline"
1414+ CiPipelineCancelPipelineNSID = "sh.tangled.ci.pipeline.cancelPipeline"
1515)
16161717-// PipelineCancelPipeline_Input is the input argument to a sh.tangled.pipeline.cancelPipeline call.
1818-type PipelineCancelPipeline_Input struct {
1919- // pipeline: pipeline at-uri
1717+// CiPipelineCancelPipeline_Input is the input argument to a sh.tangled.ci.pipeline.cancelPipeline call.
1818+type CiPipelineCancelPipeline_Input struct {
1919+ // pipeline: pipeline TID
2020 Pipeline string `json:"pipeline" cborgen:"pipeline"`
2121- // repo: repo at-uri, spindle can't resolve repo from pipeline at-uri yet
2121+ // repo: git repository DID
2222 Repo string `json:"repo" cborgen:"repo"`
2323- // workflow: workflow name
2424- Workflow string `json:"workflow" cborgen:"workflow"`
2323+ // workflows: Workflow names to filter. When not provided, entire pipeline will be canceled.
2424+ Workflows []string `json:"workflows,omitempty" cborgen:"workflows,omitempty"`
2525}
26262727-// PipelineCancelPipeline calls the XRPC method "sh.tangled.pipeline.cancelPipeline".
2828-func PipelineCancelPipeline(ctx context.Context, c util.LexClient, input *PipelineCancelPipeline_Input) error {
2929- if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.pipeline.cancelPipeline", nil, input, nil); err != nil {
2727+// CiPipelineCancelPipeline calls the XRPC method "sh.tangled.ci.pipeline.cancelPipeline".
2828+func CiPipelineCancelPipeline(ctx context.Context, c util.LexClient, input *CiPipelineCancelPipeline_Input) error {
2929+ if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.ci.pipeline.cancelPipeline", nil, input, nil); err != nil {
3030 return err
3131 }
3232
+43
api/tangled/pipelinetriggerPipeline.go
···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+package tangled
44+55+// schema: sh.tangled.ci.pipeline.triggerPipeline
66+77+import (
88+ "context"
99+1010+ "github.com/bluesky-social/indigo/lex/util"
1111+)
1212+1313+const (
1414+ CiPipelineTriggerPipelineNSID = "sh.tangled.ci.pipeline.triggerPipeline"
1515+)
1616+1717+// CiPipelineTriggerPipeline_Input is the input argument to a sh.tangled.ci.pipeline.triggerPipeline call.
1818+type CiPipelineTriggerPipeline_Input struct {
1919+ // ref: optional ref the SHA was resolved from, for display
2020+ Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"`
2121+ // repo: AT-URI of the sh.tangled.repo record
2222+ Repo string `json:"repo" cborgen:"repo"`
2323+ // sha: commit SHA to run the pipeline at
2424+ Sha string `json:"sha" cborgen:"sha"`
2525+ // workflows: Workflow names to run. When not provided, every dispatchable workflow is run.
2626+ Workflows []string `json:"workflows,omitempty" cborgen:"workflows,omitempty"`
2727+}
2828+2929+// CiPipelineTriggerPipeline_Output is the output of a sh.tangled.ci.pipeline.triggerPipeline call.
3030+type CiPipelineTriggerPipeline_Output struct {
3131+ // pipeline: AT-URI of the created pipeline
3232+ Pipeline string `json:"pipeline" cborgen:"pipeline"`
3333+}
3434+3535+// CiPipelineTriggerPipeline calls the XRPC method "sh.tangled.ci.pipeline.triggerPipeline".
3636+func CiPipelineTriggerPipeline(ctx context.Context, c util.LexClient, input *CiPipelineTriggerPipeline_Input) (*CiPipelineTriggerPipeline_Output, error) {
3737+ var out CiPipelineTriggerPipeline_Output
3838+ if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.ci.pipeline.triggerPipeline", nil, input, &out); err != nil {
3939+ return nil, err
4040+ }
4141+4242+ return &out, nil
4343+}
+4
api/tangled/tangledpipeline.go
···3535// Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema.
3636type Pipeline_ManualTriggerData struct {
3737 Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
3838+ // ref: optional ref the SHA was resolved from, for display and TANGLED_REF
3939+ Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"`
4040+ // sha: commit SHA the manual run targets
4141+ Sha string `json:"sha" cborgen:"sha"`
3842}
39434044// Pipeline_Pair is a "pair" in the sh.tangled.pipeline schema.
···66 "net/http"
77 "strings"
8899- "github.com/bluesky-social/indigo/api/atproto"
109 "github.com/bluesky-social/indigo/atproto/syntax"
1111- "github.com/bluesky-social/indigo/xrpc"
1210 "tangled.org/core/api/tangled"
1313- "tangled.org/core/rbac"
1411 "tangled.org/core/spindle/models"
1512 xrpcerr "tangled.org/core/xrpc/errors"
1613)
···2926 return
3027 }
31283232- var input tangled.PipelineCancelPipeline_Input
2929+ var input tangled.CiPipelineCancelPipeline_Input
3330 if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
3431 fail(xrpcerr.GenericError(err))
3532 return
3633 }
37343835 aturi := syntax.ATURI(input.Pipeline)
3939- wid := models.WorkflowId{
4040- PipelineId: models.PipelineId{
4141- Knot: strings.TrimPrefix(aturi.Authority().String(), "did:web:"),
4242- Rkey: aturi.RecordKey().String(),
4343- },
4444- Name: input.Workflow,
3636+ pipelineId := models.PipelineId{
3737+ Knot: strings.TrimPrefix(aturi.Authority().String(), "did:web:"),
3838+ Rkey: aturi.RecordKey().String(),
4539 }
4646- l.Debug("cancel pipeline", "wid", wid)
47404848- // unfortunately we have to resolve repo-at here
4949- repoAt, err := syntax.ParseATURI(input.Repo)
5050- if err != nil {
5151- fail(xrpcerr.InvalidRepoError(input.Repo))
5252- return
5353- }
5454-5555- ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
5656- if err != nil || ident.Handle.IsInvalidHandle() {
5757- fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
5858- return
5959- }
6060-6161- xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
6262- resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
6363- if err != nil {
6464- fail(xrpcerr.GenericError(err))
6565- return
4141+ var workflows []string
4242+ if len(input.Workflows) > 0 {
4343+ workflows = input.Workflows
4444+ } else {
4545+ // fetch workflows from db if none are specified
4646+ p, err := x.Db.GetPipeline(r.Context(), pipelineId.Rkey)
4747+ if err != nil {
4848+ fail(xrpcerr.GenericError(fmt.Errorf("failed to get pipeline: %w", err)))
4949+ return
5050+ }
5151+ for _, w := range p.Workflows {
5252+ workflows = append(workflows, w.Name)
5353+ }
6654 }
67556868- repoRec, ok := resp.Value.Val.(*tangled.Repo)
6969- if !ok {
7070- fail(xrpcerr.RepoNotFoundError)
7171- return
7272- }
7373- if repoRec.RepoDid == nil || *repoRec.RepoDid == "" {
7474- fail(xrpcerr.GenericError(fmt.Errorf("repo record %s has no repoDid", repoAt)))
5656+ if _, xerr, ok := x.resolveOwnedRepo(r.Context(), actorDid, input.Repo); !ok {
5757+ fail(xerr)
7558 return
7659 }
7777- repoDid := *repoRec.RepoDid
78607979- // TODO: fine-grained role based control
8080- isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid.String(), rbac.ThisServer, repoDid)
8181- if err != nil || !isRepoOwner {
8282- fail(xrpcerr.AccessControlError(actorDid.String()))
8383- return
8484- }
8585- for _, engine := range x.Engines {
8686- l.Debug("destroying workflow", "wid", wid)
8787- err = engine.DestroyWorkflow(r.Context(), wid)
8888- if err != nil {
8989- fail(xrpcerr.GenericError(fmt.Errorf("failed to destroy workflow: %w", err)))
9090- return
6161+ for _, wName := range workflows {
6262+ wid := models.WorkflowId{
6363+ PipelineId: pipelineId,
6464+ Name: wName,
9165 }
9292- err = x.Db.StatusCancelled(wid, "User canceled the workflow", -1, x.Notifier)
9393- if err != nil {
9494- fail(xrpcerr.GenericError(fmt.Errorf("failed to emit status failed: %w", err)))
9595- return
6666+ l.Debug("cancel pipeline", "wid", wid)
6767+6868+ for _, engine := range x.Engines {
6969+ l.Debug("destroying workflow", "wid", wid)
7070+ err := engine.DestroyWorkflow(r.Context(), wid)
7171+ if err != nil {
7272+ fail(xrpcerr.GenericError(fmt.Errorf("failed to destroy workflow: %w", err)))
7373+ return
7474+ }
7575+ err = x.Db.StatusCancelled(wid, "User canceled the workflow", -1, x.Notifier)
7676+ if err != nil {
7777+ fail(xrpcerr.GenericError(fmt.Errorf("failed to emit status failed: %w", err)))
7878+ return
7979+ }
9680 }
9781 }
9882
+19-5
spindle/xrpc/xrpc.go
···11package xrpc
2233import (
44+ "context"
45 _ "embed"
56 "encoding/json"
77+ "errors"
68 "log/slog"
79 "net/http"
8101111+ "github.com/bluesky-social/indigo/atproto/syntax"
912 "github.com/go-chi/chi/v5"
10131114 "tangled.org/core/api/tangled"
···22252326const ActorDid = serviceauth.ActorDid
24272828+// ErrNoMatchingWorkflows is returned when a manual dispatch resolves to no
2929+// workflows to run: the repo defines none at the requested commit, or none of
3030+// the requested workflow names exist.
3131+var ErrNoMatchingWorkflows = errors.New("no workflows to run")
3232+3333+// PipelineTrigger builds and enqueues a manually-dispatched pipeline. It is
3434+// implemented by *spindle.Spindle, which owns the queue and engines; the xrpc
3535+// handler only does auth and input validation before delegating here.
3636+type PipelineTrigger interface {
3737+ TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string) (syntax.ATURI, error)
3838+}
3939+2540type Xrpc struct {
2641 Logger *slog.Logger
2742 Db *db.DB
···3247 Vault secrets.Manager
3348 Notifier *notifier.Notifier
3449 ServiceAuth *serviceauth.ServiceAuth
5050+ Trigger PipelineTrigger
3551}
36523753func (x *Xrpc) Router() http.Handler {
···4359 r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
4460 r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
4561 r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
4646- r.Post("/"+tangled.PipelineCancelPipelineNSID, x.CancelPipeline)
6262+ r.Post("/"+tangled.CiPipelineCancelPipelineNSID, x.CancelPipeline)
6363+ r.Post("/"+tangled.CiPipelineTriggerPipelineNSID, x.TriggerPipeline)
4764 })
48654966 // service query endpoints (no auth required)
···6784func writeJson(w http.ResponseWriter, status int, response any) error {
6885 w.Header().Set("Content-Type", "application/json")
6986 w.WriteHeader(status)
7070- if err := json.NewEncoder(w).Encode(response); err != nil {
7171- return err
7272- }
7373- return nil
8787+ return json.NewEncoder(w).Encode(response)
7488}
+1-1
workflow/def.go
···96969797// if any of the constraints on a workflow is true, return true
9898func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata, changedFiles []string) (bool, error) {
9999- // manual triggers always run the workflow
9999+ // manual dispatch skips matching constraints since selection is done by the caller
100100 if trigger.Manual != nil {
101101 return true, nil
102102 }
+22
workflow/def_test.go
···496496 })
497497 }
498498}
499499+500500+func TestMatch_ManualDispatch(t *testing.T) {
501501+ // manual dispatch is policy-free: every workflow matches regardless of its
502502+ // declared event/branch/tag/path constraints. Selection is the caller's job.
503503+ manualTrigger := tangled.Pipeline_TriggerMetadata{
504504+ Kind: string(TriggerKindManual),
505505+ Manual: &tangled.Pipeline_ManualTriggerData{Sha: "deadbeef"},
506506+ }
507507+508508+ workflows := []Workflow{
509509+ {When: nil},
510510+ {When: []Constraint{{Event: []string{"push"}, Branch: []string{"main"}}}},
511511+ {When: []Constraint{{Event: []string{"pull_request"}, Paths: []string{"src/**"}}}},
512512+ {When: []Constraint{{Event: []string{"push"}, Tag: []string{"v*"}}}},
513513+ }
514514+515515+ for i, wf := range workflows {
516516+ result, err := wf.Match(manualTrigger, nil)
517517+ assert.NoError(t, err)
518518+ assert.True(t, result, "workflow %d should match a manual dispatch", i)
519519+ }
520520+}