Compare changes

Choose any two refs to compare.

Changed files
+256 -2
api
cmd
cborgen
docs
spindle
lexicons
pipeline
spindle
workflow
+168 -1
api/tangled/cbor_gen.go
··· 4254 4254 4255 4255 return nil 4256 4256 } 4257 + func (t *Pipeline_ScheduleTriggerData) MarshalCBOR(w io.Writer) error { 4258 + if t == nil { 4259 + _, err := w.Write(cbg.CborNull) 4260 + return err 4261 + } 4262 + 4263 + cw := cbg.NewCborWriter(w) 4264 + fieldCount := 1 4265 + 4266 + if t.CronExpression == nil { 4267 + fieldCount-- 4268 + } 4269 + 4270 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 4271 + return err 4272 + } 4273 + 4274 + // t.CronExpression (string) (string) 4275 + if t.CronExpression != nil { 4276 + 4277 + if len("cronExpression") > 1000000 { 4278 + return xerrors.Errorf("Value in field \"cronExpression\" was too long") 4279 + } 4280 + 4281 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("cronExpression"))); err != nil { 4282 + return err 4283 + } 4284 + if _, err := cw.WriteString(string("cronExpression")); err != nil { 4285 + return err 4286 + } 4287 + 4288 + if t.CronExpression == nil { 4289 + if _, err := cw.Write(cbg.CborNull); err != nil { 4290 + return err 4291 + } 4292 + } else { 4293 + if len(*t.CronExpression) > 1000000 { 4294 + return xerrors.Errorf("Value in field t.CronExpression was too long") 4295 + } 4296 + 4297 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CronExpression))); err != nil { 4298 + return err 4299 + } 4300 + if _, err := cw.WriteString(string(*t.CronExpression)); err != nil { 4301 + return err 4302 + } 4303 + } 4304 + } 4305 + return nil 4306 + } 4307 + 4308 + func (t *Pipeline_ScheduleTriggerData) UnmarshalCBOR(r io.Reader) (err error) { 4309 + *t = Pipeline_ScheduleTriggerData{} 4310 + 4311 + cr := cbg.NewCborReader(r) 4312 + 4313 + maj, extra, err := cr.ReadHeader() 4314 + if err != nil { 4315 + return err 4316 + } 4317 + defer func() { 4318 + if err == io.EOF { 4319 + err = io.ErrUnexpectedEOF 4320 + } 4321 + }() 4322 + 4323 + if maj != cbg.MajMap { 4324 + return fmt.Errorf("cbor input should be of type map") 4325 + } 4326 + 4327 + if extra > cbg.MaxLength { 4328 + return fmt.Errorf("Pipeline_ScheduleTriggerData: map struct too large (%d)", extra) 4329 + } 4330 + 4331 + n := extra 4332 + 4333 + nameBuf := make([]byte, 14) 4334 + for i := uint64(0); i < n; i++ { 4335 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4336 + if err != nil { 4337 + return err 4338 + } 4339 + 4340 + if !ok { 4341 + // Field doesn't exist on this type, so ignore it 4342 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 4343 + return err 4344 + } 4345 + continue 4346 + } 4347 + 4348 + switch string(nameBuf[:nameLen]) { 4349 + // t.CronExpression (string) (string) 4350 + case "cronExpression": 4351 + 4352 + { 4353 + b, err := cr.ReadByte() 4354 + if err != nil { 4355 + return err 4356 + } 4357 + if b != cbg.CborNull[0] { 4358 + if err := cr.UnreadByte(); err != nil { 4359 + return err 4360 + } 4361 + 4362 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4363 + if err != nil { 4364 + return err 4365 + } 4366 + 4367 + t.CronExpression = (*string)(&sval) 4368 + } 4369 + } 4370 + 4371 + default: 4372 + // Field doesn't exist on this type, so ignore it 4373 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 4374 + return err 4375 + } 4376 + } 4377 + } 4378 + 4379 + return nil 4380 + } 4257 4381 func (t *Pipeline_PullRequestTriggerData) MarshalCBOR(w io.Writer) error { 4258 4382 if t == nil { 4259 4383 _, err := w.Write(cbg.CborNull) ··· 4993 5117 } 4994 5118 4995 5119 cw := cbg.NewCborWriter(w) 4996 - fieldCount := 5 5120 + fieldCount := 6 4997 5121 4998 5122 if t.Manual == nil { 4999 5123 fieldCount-- ··· 5007 5131 fieldCount-- 5008 5132 } 5009 5133 5134 + if t.Schedule == nil { 5135 + fieldCount-- 5136 + } 5137 + 5010 5138 if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 5011 5139 return err 5012 5140 } ··· 5088 5216 } 5089 5217 } 5090 5218 5219 + // t.Schedule (tangled.Pipeline_ScheduleTriggerData) (struct) 5220 + if t.Schedule != nil { 5221 + 5222 + if len("schedule") > 1000000 { 5223 + return xerrors.Errorf("Value in field \"schedule\" was too long") 5224 + } 5225 + 5226 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("schedule"))); err != nil { 5227 + return err 5228 + } 5229 + if _, err := cw.WriteString(string("schedule")); err != nil { 5230 + return err 5231 + } 5232 + 5233 + if err := t.Schedule.MarshalCBOR(cw); err != nil { 5234 + return err 5235 + } 5236 + } 5237 + 5091 5238 // t.PullRequest (tangled.Pipeline_PullRequestTriggerData) (struct) 5092 5239 if t.PullRequest != nil { 5093 5240 ··· 5220 5367 } 5221 5368 } 5222 5369 5370 + } 5371 + // t.Schedule (tangled.Pipeline_ScheduleTriggerData) (struct) 5372 + case "schedule": 5373 + 5374 + { 5375 + 5376 + b, err := cr.ReadByte() 5377 + if err != nil { 5378 + return err 5379 + } 5380 + if b != cbg.CborNull[0] { 5381 + if err := cr.UnreadByte(); err != nil { 5382 + return err 5383 + } 5384 + t.Schedule = new(Pipeline_ScheduleTriggerData) 5385 + if err := t.Schedule.UnmarshalCBOR(cr); err != nil { 5386 + return xerrors.Errorf("unmarshaling t.Schedule pointer: %w", err) 5387 + } 5388 + } 5389 + 5223 5390 } 5224 5391 // t.PullRequest (tangled.Pipeline_PullRequestTriggerData) (struct) 5225 5392 case "pullRequest":
+6
api/tangled/tangledpipeline.go
··· 55 55 Ref string `json:"ref" cborgen:"ref"` 56 56 } 57 57 58 + // Pipeline_ScheduleTriggerData is a "scheduleTriggerData" in the sh.tangled.pipeline schema. 59 + type Pipeline_ScheduleTriggerData struct { 60 + CronExpression *string `json:"cronExpression,omitempty" cborgen:"cronExpression,omitempty"` 61 + } 62 + 58 63 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 59 64 type Pipeline_TriggerMetadata struct { 60 65 Kind string `json:"kind" cborgen:"kind"` ··· 62 67 PullRequest *Pipeline_PullRequestTriggerData `json:"pullRequest,omitempty" cborgen:"pullRequest,omitempty"` 63 68 Push *Pipeline_PushTriggerData `json:"push,omitempty" cborgen:"push,omitempty"` 64 69 Repo *Pipeline_TriggerRepo `json:"repo" cborgen:"repo"` 70 + Schedule *Pipeline_ScheduleTriggerData `json:"schedule,omitempty" cborgen:"schedule,omitempty"` 65 71 } 66 72 67 73 // Pipeline_TriggerRepo is a "triggerRepo" in the sh.tangled.pipeline schema.
+1
cmd/cborgen/cborgen.go
··· 34 34 tangled.Pipeline_CloneOpts{}, 35 35 tangled.Pipeline_ManualTriggerData{}, 36 36 tangled.Pipeline_Pair{}, 37 + tangled.Pipeline_ScheduleTriggerData{}, 37 38 tangled.Pipeline_PullRequestTriggerData{}, 38 39 tangled.Pipeline_PushTriggerData{}, 39 40 tangled.PipelineStatus{},
+2
docs/spindle/pipeline.md
··· 18 18 - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 19 - `push`: The workflow should run every time a commit is pushed to the repository. 20 20 - `pull_request`: The workflow should run every time a pull request is made or updated. 21 + - `schedule`: The workflow should run on a schedule. 21 22 - `manual`: The workflow can be triggered manually. 22 23 - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 24 - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 25 + - `cron`: This field is **required** when using the `schedule` event. It defines the cron expression that specifies when the workflow should run. The cron syntax follows the [standard crontab format](https://www.man7.org/linux/man-pages/man5/crontab.5.html). For example, to run a workflow every day at midnight, you would use `0 0 * * *`. All jobs are run in UTC. 24 26 25 27 For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 26 28
+4 -1
go.mod
··· 41 41 github.com/sethvargo/go-envconfig v1.1.0 42 42 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 43 43 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 44 - github.com/stretchr/testify v1.10.0 44 + github.com/stretchr/testify v1.11.1 45 45 github.com/urfave/cli/v3 v3.3.3 46 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 47 github.com/wyatt915/goldmark-treeblood v0.0.1 ··· 106 106 github.com/emirpasic/gods v1.18.1 // indirect 107 107 github.com/felixge/httpsnoop v1.0.4 // indirect 108 108 github.com/fsnotify/fsnotify v1.6.0 // indirect 109 + github.com/go-co-op/gocron/v2 v2.18.2 // indirect 109 110 github.com/go-enry/go-oniguruma v1.2.1 // indirect 110 111 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 111 112 github.com/go-git/go-billy/v5 v5.6.2 // indirect ··· 147 148 github.com/ipfs/go-log v1.0.5 // indirect 148 149 github.com/ipfs/go-log/v2 v2.6.0 // indirect 149 150 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 151 + github.com/jonboulle/clockwork v0.5.0 // indirect 150 152 github.com/json-iterator/go v1.1.12 // indirect 151 153 github.com/kevinburke/ssh_config v1.2.0 // indirect 152 154 github.com/klauspost/compress v1.18.0 // indirect ··· 184 186 github.com/prometheus/common v0.64.0 // indirect 185 187 github.com/prometheus/procfs v0.16.1 // indirect 186 188 github.com/rivo/uniseg v0.4.7 // indirect 189 + github.com/robfig/cron/v3 v3.0.1 // indirect 187 190 github.com/ryanuber/go-glob v1.0.0 // indirect 188 191 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 189 192 github.com/spaolacci/murmur3 v1.1.0 // indirect
+8
go.sum
··· 160 160 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 161 161 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= 162 162 github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 163 + github.com/go-co-op/gocron/v2 v2.18.2 h1:+5VU41FUXPWSPKLXZQ/77SGzUiPCcakU0v7ENc2H20Q= 164 + github.com/go-co-op/gocron/v2 v2.18.2/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI= 163 165 github.com/go-enry/go-enry/v2 v2.9.2 h1:giOQAtCgBX08kosrX818DCQJTCNtKwoPBGu0qb6nKTY= 164 166 github.com/go-enry/go-enry/v2 v2.9.2/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= 165 167 github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= ··· 306 308 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 307 309 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 308 310 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 311 + github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= 312 + github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= 309 313 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 310 314 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 311 315 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 444 448 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 445 449 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 446 450 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 451 + github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 452 + github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 447 453 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 448 454 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 449 455 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 481 487 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 482 488 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 483 489 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 490 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 491 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 484 492 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 485 493 github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 486 494 github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
+13
lexicons/pipeline/pipeline.json
··· 40 40 "enum": [ 41 41 "push", 42 42 "pull_request", 43 + "schedule", 43 44 "manual" 44 45 ] 45 46 }, ··· 55 56 "type": "ref", 56 57 "ref": "#pullRequestTriggerData" 57 58 }, 59 + "schedule": { 60 + "type": "ref", 61 + "ref": "#scheduleTriggerData" 62 + }, 58 63 "manual": { 59 64 "type": "ref", 60 65 "ref": "#manualTriggerData" ··· 133 138 } 134 139 } 135 140 }, 141 + "scheduleTriggerData": { 142 + "type": "object", 143 + "properties": { 144 + "cronExpression": { 145 + "type": "string" 146 + } 147 + } 148 + }, 136 149 "manualTriggerData": { 137 150 "type": "object", 138 151 "properties": {
+18
spindle/server.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "net/http" 10 + "time" 10 11 11 12 "github.com/go-chi/chi/v5" 13 + "github.com/go-co-op/gocron/v2" 14 + 12 15 "tangled.org/core/api/tangled" 13 16 "tangled.org/core/eventconsumer" 14 17 "tangled.org/core/eventconsumer/cursor" ··· 47 50 ks *eventconsumer.Consumer 48 51 res *idresolver.Resolver 49 52 vault secrets.Manager 53 + crons gocron.Scheduler 50 54 } 51 55 52 56 // New creates a new Spindle server with the provided configuration and engines. ··· 116 120 117 121 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 118 122 123 + scheduler, err := gocron.NewScheduler(gocron.WithLocation(time.UTC)) 124 + if err != nil { 125 + return nil, fmt.Errorf("failed to create cron scheduler: %w", err) 126 + } 127 + defer func() { _ = scheduler.Shutdown() }() 128 + 119 129 spindle := &Spindle{ 120 130 jc: jc, 121 131 e: e, ··· 127 137 cfg: cfg, 128 138 res: resolver, 129 139 vault: vault, 140 + crons: scheduler, 130 141 } 131 142 132 143 err = e.AddSpindle(rbacDomain) ··· 200 211 return s.e 201 212 } 202 213 214 + // Scheduler returns the cron scheduler instance. 215 + func (s *Spindle) Scheduler() *gocron.Scheduler { 216 + return &s.crons 217 + } 218 + 203 219 // Start starts the Spindle server (blocking). 204 220 func (s *Spindle) Start(ctx context.Context) error { 205 221 // starts a job queue runner in the background ··· 211 227 defer stopper.Stop() 212 228 } 213 229 230 + s.crons.Start() 231 + 214 232 go func() { 215 233 s.l.Info("starting knot event consumer") 216 234 s.ks.Start(ctx)
+20
workflow/def.go
··· 9 9 "tangled.org/core/api/tangled" 10 10 11 11 "github.com/bmatcuk/doublestar/v4" 12 + "github.com/go-co-op/gocron/v2" 12 13 "github.com/go-git/go-git/v5/plumbing" 13 14 "gopkg.in/yaml.v3" 14 15 ) ··· 36 37 Event StringList `yaml:"event"` 37 38 Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified 38 39 Tag StringList `yaml:"tag"` // optional; only applies to push events 40 + Cron StringList `yaml:"cron"` // required for schedule events 39 41 } 40 42 41 43 CloneOpts struct { ··· 54 56 55 57 TriggerKindPush TriggerKind = "push" 56 58 TriggerKindPullRequest TriggerKind = "pull_request" 59 + TriggerKindSchedule TriggerKind = "schedule" 57 60 TriggerKindManual TriggerKind = "manual" 58 61 ) 59 62 ··· 147 150 match = match && matched 148 151 } 149 152 153 + // apply cron constraints for schedules 154 + if trigger.Schedule != nil { 155 + s, _ := gocron.NewScheduler() 156 + 157 + _, err := s.NewJob(gocron.CronJob( 158 + *trigger.Schedule.CronExpression, 159 + false, 160 + ), 161 + gocron.NewTask( 162 + func() {}, 163 + ), 164 + ) 165 + if err != nil { 166 + return false, err 167 + } 168 + } 169 + 150 170 return match, nil 151 171 } 152 172
+16
workflow/def_test.go
··· 68 68 assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag) 69 69 } 70 70 71 + func TestUnmarshalWorkflowWithCron(t *testing.T) { 72 + yamlData := ` 73 + when: 74 + - event: ["schedule"] 75 + cron: "0 0 * * *"` 76 + 77 + wf, err := FromFile("test.yml", []byte(yamlData)) 78 + assert.NoError(t, err, "YAML should unmarshal without error") 79 + 80 + assert.Len(t, wf.When, 1, "Should have one constraint") 81 + assert.ElementsMatch(t, []string{"schedule"}, wf.When[0].Event) 82 + assert.ElementsMatch(t, []string{"0 0 * * *"}, wf.When[0].Cron) 83 + 84 + assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 85 + } 86 + 71 87 func TestMatchesPattern(t *testing.T) { 72 88 tests := []struct { 73 89 name string