diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index 2e180c73a..46f71cccc 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -1087,7 +1087,7 @@ func (t *CiPipeline_Trigger) MarshalCBOR(w io.Writer) error { cw := cbg.NewCborWriter(w) - if _, err := cw.Write([]byte{163}); err != nil { + if _, err := cw.Write([]byte{164}); err != nil { return err } @@ -1123,6 +1123,22 @@ func (t *CiPipeline_Trigger) MarshalCBOR(w io.Writer) error { return err } + // t.CiTrigger_Schedule (tangled.CiTrigger_Schedule) (struct) + if len("CiTrigger_Schedule") > 1000000 { + return xerrors.Errorf("Value in field \"CiTrigger_Schedule\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("CiTrigger_Schedule"))); err != nil { + return err + } + if _, err := cw.WriteString(string("CiTrigger_Schedule")); err != nil { + return err + } + + if err := t.CiTrigger_Schedule.MarshalCBOR(cw); err != nil { + return err + } + // t.CiTrigger_PullRequest (tangled.CiTrigger_PullRequest) (struct) if len("CiTrigger_PullRequest") > 1000000 { return xerrors.Errorf("Value in field \"CiTrigger_PullRequest\" was too long") @@ -1221,6 +1237,26 @@ func (t *CiPipeline_Trigger) UnmarshalCBOR(r io.Reader) (err error) { } } + } + // t.CiTrigger_Schedule (tangled.CiTrigger_Schedule) (struct) + case "CiTrigger_Schedule": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.CiTrigger_Schedule = new(CiTrigger_Schedule) + if err := t.CiTrigger_Schedule.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.CiTrigger_Schedule pointer: %w", err) + } + } + } // t.CiTrigger_PullRequest (tangled.CiTrigger_PullRequest) (struct) case "CiTrigger_PullRequest": @@ -3283,6 +3319,204 @@ func (t *CiTrigger_Push) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *CiTrigger_Schedule) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{164}); err != nil { + return err + } + + // t.Ref (string) (string) + if len("ref") > 1000000 { + return xerrors.Errorf("Value in field \"ref\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("ref"))); err != nil { + return err + } + if _, err := cw.WriteString(string("ref")); err != nil { + return err + } + + if len(t.Ref) > 1000000 { + return xerrors.Errorf("Value in field t.Ref was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Ref))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Ref)); err != nil { + return err + } + + // t.Sha (string) (string) + if len("sha") > 1000000 { + return xerrors.Errorf("Value in field \"sha\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sha"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sha")); err != nil { + return err + } + + if len(t.Sha) > 1000000 { + return xerrors.Errorf("Value in field t.Sha was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Sha))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Sha)); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.ci.trigger#schedule"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sh.tangled.ci.trigger#schedule")); err != nil { + return err + } + + // t.ScheduledAt (string) (string) + if len("scheduledAt") > 1000000 { + return xerrors.Errorf("Value in field \"scheduledAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("scheduledAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("scheduledAt")); err != nil { + return err + } + + if len(t.ScheduledAt) > 1000000 { + return xerrors.Errorf("Value in field t.ScheduledAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ScheduledAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.ScheduledAt)); err != nil { + return err + } + return nil +} + +func (t *CiTrigger_Schedule) UnmarshalCBOR(r io.Reader) (err error) { + *t = CiTrigger_Schedule{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("CiTrigger_Schedule: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 11) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Ref (string) (string) + case "ref": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Ref = string(sval) + } + // t.Sha (string) (string) + case "sha": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Sha = string(sval) + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.ScheduledAt (string) (string) + case "scheduledAt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.ScheduledAt = string(sval) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} func (t *FeedComment) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) @@ -9655,6 +9889,174 @@ func (t *Pipeline_PushTriggerData) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *Pipeline_ScheduleTriggerData) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{163}); err != nil { + return err + } + + // t.Ref (string) (string) + if len("ref") > 1000000 { + return xerrors.Errorf("Value in field \"ref\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("ref"))); err != nil { + return err + } + if _, err := cw.WriteString(string("ref")); err != nil { + return err + } + + if len(t.Ref) > 1000000 { + return xerrors.Errorf("Value in field t.Ref was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Ref))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Ref)); err != nil { + return err + } + + // t.Sha (string) (string) + if len("sha") > 1000000 { + return xerrors.Errorf("Value in field \"sha\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sha"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sha")); err != nil { + return err + } + + if len(t.Sha) > 1000000 { + return xerrors.Errorf("Value in field t.Sha was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Sha))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Sha)); err != nil { + return err + } + + // t.ScheduledAt (string) (string) + if len("scheduledAt") > 1000000 { + return xerrors.Errorf("Value in field \"scheduledAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("scheduledAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("scheduledAt")); err != nil { + return err + } + + if len(t.ScheduledAt) > 1000000 { + return xerrors.Errorf("Value in field t.ScheduledAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ScheduledAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.ScheduledAt)); err != nil { + return err + } + return nil +} + +func (t *Pipeline_ScheduleTriggerData) UnmarshalCBOR(r io.Reader) (err error) { + *t = Pipeline_ScheduleTriggerData{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("Pipeline_ScheduleTriggerData: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 11) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Ref (string) (string) + case "ref": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Ref = string(sval) + } + // t.Sha (string) (string) + case "sha": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Sha = string(sval) + } + // t.ScheduledAt (string) (string) + case "scheduledAt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.ScheduledAt = string(sval) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} func (t *PipelineStatus) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) @@ -10024,7 +10426,7 @@ func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 6 + fieldCount := 7 if t.Manual == nil { fieldCount-- @@ -10038,6 +10440,10 @@ func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.Schedule == nil { + fieldCount-- + } + if t.SourceRepo == nil { fieldCount-- } @@ -10123,6 +10529,25 @@ func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { } } + // t.Schedule (tangled.Pipeline_ScheduleTriggerData) (struct) + if t.Schedule != nil { + + if len("schedule") > 1000000 { + return xerrors.Errorf("Value in field \"schedule\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("schedule"))); err != nil { + return err + } + if _, err := cw.WriteString(string("schedule")); err != nil { + return err + } + + if err := t.Schedule.MarshalCBOR(cw); err != nil { + return err + } + } + // t.SourceRepo (string) (string) if t.SourceRepo != nil { @@ -10287,6 +10712,26 @@ func (t *Pipeline_TriggerMetadata) UnmarshalCBOR(r io.Reader) (err error) { } } + } + // t.Schedule (tangled.Pipeline_ScheduleTriggerData) (struct) + case "schedule": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Schedule = new(Pipeline_ScheduleTriggerData) + if err := t.Schedule.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Schedule pointer: %w", err) + } + } + } // t.SourceRepo (string) (string) case "sourceRepo": diff --git a/api/tangled/cipipeline.go b/api/tangled/cipipeline.go index 290917ad9..68a8df130 100644 --- a/api/tangled/cipipeline.go +++ b/api/tangled/cipipeline.go @@ -39,6 +39,7 @@ type CiPipeline_Trigger struct { CiTrigger_Push *CiTrigger_Push CiTrigger_PullRequest *CiTrigger_PullRequest CiTrigger_Manual *CiTrigger_Manual + CiTrigger_Schedule *CiTrigger_Schedule } func (t *CiPipeline_Trigger) MarshalJSON() ([]byte, error) { @@ -54,6 +55,10 @@ func (t *CiPipeline_Trigger) MarshalJSON() ([]byte, error) { t.CiTrigger_Manual.LexiconTypeID = "sh.tangled.ci.trigger#manual" return json.Marshal(t.CiTrigger_Manual) } + if t.CiTrigger_Schedule != nil { + t.CiTrigger_Schedule.LexiconTypeID = "sh.tangled.ci.trigger#schedule" + return json.Marshal(t.CiTrigger_Schedule) + } return nil, fmt.Errorf("cannot marshal empty enum") } func (t *CiPipeline_Trigger) UnmarshalJSON(b []byte) error { @@ -72,6 +77,9 @@ func (t *CiPipeline_Trigger) UnmarshalJSON(b []byte) error { case "sh.tangled.ci.trigger#manual": t.CiTrigger_Manual = new(CiTrigger_Manual) return json.Unmarshal(b, t.CiTrigger_Manual) + case "sh.tangled.ci.trigger#schedule": + t.CiTrigger_Schedule = new(CiTrigger_Schedule) + return json.Unmarshal(b, t.CiTrigger_Schedule) default: return nil diff --git a/api/tangled/citrigger.go b/api/tangled/citrigger.go index 181a3ba98..754486562 100644 --- a/api/tangled/citrigger.go +++ b/api/tangled/citrigger.go @@ -53,3 +53,16 @@ type CiTrigger_Push struct { OldSha string `json:"oldSha" cborgen:"oldSha"` Ref string `json:"ref" cborgen:"ref"` } + +// CiTrigger_Schedule is a "schedule" in the sh.tangled.ci.trigger schema. +// +// RECORDTYPE: CiTrigger_Schedule +type CiTrigger_Schedule struct { + LexiconTypeID string `json:"$type,const=sh.tangled.ci.trigger#schedule" cborgen:"$type,const=sh.tangled.ci.trigger#schedule"` + // ref: default branch ref resolved for the scheduled run + Ref string `json:"ref" cborgen:"ref"` + // scheduledAt: UTC minute selected by the cron schedule + ScheduledAt string `json:"scheduledAt" cborgen:"scheduledAt"` + // sha: commit SHA the scheduled run targets + Sha string `json:"sha" cborgen:"sha"` +} diff --git a/api/tangled/tangledpipeline.go b/api/tangled/tangledpipeline.go index ae0d5f5fe..287b91b1c 100644 --- a/api/tangled/tangledpipeline.go +++ b/api/tangled/tangledpipeline.go @@ -65,6 +65,16 @@ type Pipeline_PushTriggerData struct { Ref string `json:"ref" cborgen:"ref"` } +// Pipeline_ScheduleTriggerData is a "scheduleTriggerData" in the sh.tangled.pipeline schema. +type Pipeline_ScheduleTriggerData struct { + // ref: default branch ref resolved for the scheduled run + Ref string `json:"ref" cborgen:"ref"` + // scheduledAt: UTC minute selected by the cron schedule + ScheduledAt string `json:"scheduledAt" cborgen:"scheduledAt"` + // sha: commit SHA the scheduled run targets + Sha string `json:"sha" cborgen:"sha"` +} + // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. type Pipeline_TriggerMetadata struct { Kind string `json:"kind" cborgen:"kind"` @@ -72,6 +82,7 @@ type Pipeline_TriggerMetadata struct { PullRequest *Pipeline_PullRequestTriggerData `json:"pullRequest,omitempty" cborgen:"pullRequest,omitempty"` Push *Pipeline_PushTriggerData `json:"push,omitempty" cborgen:"push,omitempty"` Repo *Pipeline_TriggerRepo `json:"repo" cborgen:"repo"` + Schedule *Pipeline_ScheduleTriggerData `json:"schedule,omitempty" cborgen:"schedule,omitempty"` // sourceRepo: Repository DID that code and workflow definitions are checked out from, when different from repo (e.g. a fork's commit for a fork-based manual trigger). If absent, source uses repo itself. SourceRepo *string `json:"sourceRepo,omitempty" cborgen:"sourceRepo,omitempty"` } diff --git a/cmd/cborgen/cborgen.go b/cmd/cborgen/cborgen.go index 4df8f7124..7abbf2c34 100644 --- a/cmd/cborgen/cborgen.go +++ b/cmd/cborgen/cborgen.go @@ -23,6 +23,7 @@ func main() { tangled.CiTrigger_Pair{}, tangled.CiTrigger_PullRequest{}, tangled.CiTrigger_Push{}, + tangled.CiTrigger_Schedule{}, tangled.FeedComment{}, tangled.FeedReaction{}, tangled.FeedStar{}, @@ -54,6 +55,7 @@ func main() { tangled.Pipeline_Pair{}, tangled.Pipeline_PullRequestTriggerData{}, tangled.Pipeline_PushTriggerData{}, + tangled.Pipeline_ScheduleTriggerData{}, tangled.PipelineStatus{}, tangled.Pipeline_TriggerMetadata{}, tangled.Pipeline_TriggerRepo{}, diff --git a/docs/DOCS.md b/docs/DOCS.md index b8258d602..b69e9ac8b 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -1173,6 +1173,9 @@ has the following fields: - `pull_request`: The workflow should run every time a pull request is made or updated. - `manual`: The workflow can be triggered manually. + - `schedule`: The workflow runs according to its `cron` + expressions on the repository's latest default-branch + commit. - `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 @@ -1189,6 +1192,14 @@ has the following fields: `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. +- `schedule`: A list of scheduled trigger entries. This + field is required for `schedule` events and invalid for + other events. Each entry requires a `cron` field containing + a five-field expression (`minute hour day-of-month month + day-of-week`). Expressions are evaluated in UTC. Hashed `H` + fields are supported; seconds, nicknames such as `@daily`, + and timezone prefixes are not. A condition containing + `schedule` cannot also specify `branch`, `tag`, `types`, or `paths`. For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` @@ -1223,6 +1234,28 @@ when: tag: ["v*", "stable"] ``` +Scheduled workflows run on the latest commit of the default +branch. For example, this runs at a stable minute during the +02:00 UTC hour every day and at 09:00 UTC on weekdays: + +```yaml +when: + - event: schedule + schedule: + - cron: "H 2 * * *" + - cron: "0 9 * * 1-5" +``` + +`H` spreads scheduled work using a deterministic hash of the +repository DID and workflow filename. The minute stays stable +across restarts; changing either identity changes the hash. +Ranges and steps are supported, for example `H(0-29)/10` +or `H/15`. Unbounded day-of-month hashes use days 1–28. +Exact expressions remain exact. + +Spindle evaluates schedules once per minute. Occurrences +while the spindle is offline are not replayed. + To skip CI for a push, pass a Git push option: ```sh @@ -1314,8 +1347,8 @@ By default, the following environment variables are set: - `CI` - Always set to `true` to indicate a CI environment - `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline -- `TANGLED_PIPELINE_KIND` - One of `push`, `pull_request` or - `manual` +- `TANGLED_PIPELINE_KIND` - One of `push`, `pull_request`, + `manual` or `schedule` - `TANGLED_REPO_KNOT` - The repository's knot hostname - `TANGLED_REPO_DID` - The DID of the repository owner - `TANGLED_REPO_NAME` - The name of the repository @@ -1323,8 +1356,10 @@ By default, the following environment variables are set: repository - `TANGLED_REPO_URL` - The full URL to the repository -These variables are only available when the pipeline is -triggered by a push: +These variables identify the selected git revision. SHA +variables are available for every trigger kind; ref variables +are available for pushes, pull requests, schedules, and manual +runs that specify a ref: - `TANGLED_REF` - The full git reference (e.g., `refs/heads/main` or `refs/tags/v1.0.0`) @@ -1335,6 +1370,11 @@ triggered by a push: - `TANGLED_SHA` - The commit SHA that triggered the pipeline - `TANGLED_COMMIT_SHA` - Alias for `TANGLED_SHA` +Schedule triggers additionally provide: + +- `TANGLED_SCHEDULED_AT` - The UTC minute selected by the + cron expression + These variables are only available when the pipeline is triggered by a pull request: diff --git a/go.mod b/go.mod index 9cc543e8e..0cf92629b 100644 --- a/go.mod +++ b/go.mod @@ -67,6 +67,7 @@ require ( github.com/prometheus/procfs v0.19.2 github.com/redis/go-redis/v9 v9.7.3 github.com/resend/resend-go/v3 v3.5.0 + github.com/robfig/cron/v3 v3.0.1 github.com/samber/lo v1.38.1 github.com/sethvargo/go-envconfig v1.1.0 github.com/sourcegraph/zoekt v0.0.0-20260605122240-893a523804f1 diff --git a/go.sum b/go.sum index 9a48e483d..0ad853346 100644 --- a/go.sum +++ b/go.sum @@ -746,6 +746,8 @@ github.com/resend/resend-go/v3 v3.5.0 h1:yScYxHinY352Mj7Cn9rbWsR2gDqD2mtFPWwh2Uy github.com/resend/resend-go/v3 v3.5.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= diff --git a/lexicons/ci/pipeline.json b/lexicons/ci/pipeline.json index 33e81cc4f..98e239320 100644 --- a/lexicons/ci/pipeline.json +++ b/lexicons/ci/pipeline.json @@ -21,7 +21,8 @@ "refs": [ "sh.tangled.ci.trigger#push", "sh.tangled.ci.trigger#pullRequest", - "sh.tangled.ci.trigger#manual" + "sh.tangled.ci.trigger#manual", + "sh.tangled.ci.trigger#schedule" ], "description": "Trigger event metadata" }, diff --git a/lexicons/ci/queryPipelines.json b/lexicons/ci/queryPipelines.json index 86bea8c46..e29b42fe5 100644 --- a/lexicons/ci/queryPipelines.json +++ b/lexicons/ci/queryPipelines.json @@ -26,7 +26,7 @@ "type": "array", "items": { "type": "string", - "enum": ["push", "pull_request", "manual"] + "enum": ["push", "pull_request", "manual", "schedule"] }, "description": "Filter pipelines by trigger kind. When provided, pipelines matching any listed kind are returned; when omitted, every kind is returned." }, diff --git a/lexicons/ci/trigger.json b/lexicons/ci/trigger.json index 9974ca7b5..9b3dfea7f 100644 --- a/lexicons/ci/trigger.json +++ b/lexicons/ci/trigger.json @@ -82,6 +82,27 @@ } } }, + "schedule": { + "type": "object", + "required": ["sha", "ref", "scheduledAt"], + "properties": { + "sha": { + "type": "string", + "description": "commit SHA the scheduled run targets", + "minLength": 40, + "maxLength": 40 + }, + "ref": { + "type": "string", + "description": "default branch ref resolved for the scheduled run" + }, + "scheduledAt": { + "type": "string", + "format": "datetime", + "description": "UTC minute selected by the cron schedule" + } + } + }, "pair": { "type": "object", "required": ["key", "value"], diff --git a/lexicons/pipeline/pipeline.json b/lexicons/pipeline/pipeline.json index 80b1626ef..24209d488 100644 --- a/lexicons/pipeline/pipeline.json +++ b/lexicons/pipeline/pipeline.json @@ -41,7 +41,8 @@ "enum": [ "push", "pull_request", - "manual" + "manual", + "schedule" ] }, "repo": { @@ -60,6 +61,10 @@ "type": "ref", "ref": "#manualTriggerData" }, + "schedule": { + "type": "ref", + "ref": "#scheduleTriggerData" + }, "sourceRepo": { "type": "string", "format": "did", @@ -180,6 +185,31 @@ } } }, + "scheduleTriggerData": { + "type": "object", + "required": [ + "sha", + "ref", + "scheduledAt" + ], + "properties": { + "sha": { + "type": "string", + "description": "commit SHA the scheduled run targets", + "minLength": 40, + "maxLength": 40 + }, + "ref": { + "type": "string", + "description": "default branch ref resolved for the scheduled run" + }, + "scheduledAt": { + "type": "string", + "format": "datetime", + "description": "UTC minute selected by the cron schedule" + } + } + }, "workflow": { "type": "object", "required": [ diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml index 235f784ea..f8a96134e 100644 --- a/nix/gomod2nix.toml +++ b/nix/gomod2nix.toml @@ -755,6 +755,9 @@ schema = 3 [mod."github.com/rivo/uniseg"] version = "v0.4.7" hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" + [mod."github.com/robfig/cron/v3"] + version = "v3.0.1" + hash = "sha256-FUdqNbWYi5biQc/tjCeqzxu4iy4ot1ZvDU1M1wRf/6k=" [mod."github.com/rs/xid"] version = "v1.6.0" hash = "sha256-rJB7h3KuH1DPp5n4dY3MiGnV1Y96A10lf5OUl+MLkzU=" diff --git a/workflow/def.go b/workflow/def.go index 14467b187..c93fb46bc 100644 --- a/workflow/def.go +++ b/workflow/def.go @@ -1,15 +1,21 @@ package workflow import ( + "crypto/sha256" + "encoding/binary" "errors" "fmt" "slices" + "strconv" "strings" + "time" "tangled.org/core/api/tangled" "github.com/bmatcuk/doublestar/v4" + "github.com/go-git/go-git/v5/plumbing" + "github.com/robfig/cron/v3" "gopkg.in/yaml.v3" ) @@ -34,11 +40,16 @@ type ( } Constraint struct { - Event StringList `yaml:"event"` - Types StringList `yaml:"types"` // optional; only applies to pull_request events. defaults to opened, reopened and synchronize - Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified - Tag StringList `yaml:"tag"` // optional; only applies to push events - Paths StringList `yaml:"paths"` // optional; only run if any changed file matches a glob pattern + Event StringList `yaml:"event"` + Types StringList `yaml:"types"` // optional; only applies to pull_request events. defaults to opened, reopened and synchronize + Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified + Tag StringList `yaml:"tag"` // optional; only applies to push events + Paths StringList `yaml:"paths"` // optional; only run if any changed file matches a glob pattern + Schedule []Schedule `yaml:"schedule"` // required for schedule events + } + + Schedule struct { + Cron string `yaml:"cron"` } CloneOpts struct { @@ -59,6 +70,7 @@ const ( TriggerKindPush TriggerKind = "push" TriggerKindPullRequest TriggerKind = "pull_request" TriggerKindManual TriggerKind = "manual" + TriggerKindSchedule TriggerKind = "schedule" // pull_request lifecycle actions, carried in the trigger metadata and // matched against a constraint's `types` list. @@ -83,6 +95,204 @@ func (t TriggerKind) String() string { return strings.ReplaceAll(string(t), "_", " ") } +var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + +// HasHashedCron reports whether a comma-separated cron term starts with +// Jenkins-style hashed syntax. It intentionally reports malformed uses of H +// such as Hfoo as well, so a caller can warn before ParseCron returns its error. +func HasHashedCron(expression string) bool { + for _, field := range strings.Fields(expression) { + for _, term := range strings.Split(field, ",") { + if strings.HasPrefix(term, "H") { + return true + } + } + } + return false +} + +// ParseCron parses a five-field UTC cron expression using seed to +// deterministically expand Jenkins-style hashed fields. +func ParseCron(expression, seed string) (cron.Schedule, error) { + return parseCron(expression, seed, true) +} + +func parseCron(expression, seed string, validateOccurrence bool) (cron.Schedule, error) { + if len(strings.Fields(expression)) != 5 { + return nil, fmt.Errorf("expected exactly 5 fields") + } + + if HasHashedCron(expression) { + fields := strings.Fields(expression) + for i, field := range fields { + expanded, err := expandHashedCronField(field, i, seed) + if err != nil { + return nil, err + } + fields[i] = expanded + } + expression = strings.Join(fields, " ") + } + + schedule, err := cronParser.Parse("CRON_TZ=UTC " + expression) + if err != nil { + return nil, err + } + if validateOccurrence { + if err := validateCronHasOccurrence(schedule); err != nil { + return nil, err + } + } + return schedule, nil +} + +// cronFieldBounds returns the bounds accepted for a hashed term. Jenkins +// limits an unbounded day-of-month hash to 1..28 so it is safe in every month. +func cronFieldBounds(field int, explicitRange bool) (int, int, error) { + switch field { + case 0: + return 0, 59, nil + case 1: + return 0, 23, nil + case 2: + if !explicitRange { + return 1, 28, nil + } + return 1, 31, nil + case 3: + return 1, 12, nil + case 4: + return 0, 6, nil + default: + return 0, 0, fmt.Errorf("invalid cron field index %d", field) + } +} + +func hashedCronValue(seed string, field int) uint64 { + digest := sha256.Sum256([]byte(seed + "\x00" + strconv.Itoa(field))) + return binary.BigEndian.Uint64(digest[:8]) +} + +func parseHashedCronNumber(value string) (int, error) { + if value == "" { + return 0, fmt.Errorf("number is required") + } + for i := range value { + if value[i] < '0' || value[i] > '9' { + return 0, fmt.Errorf("%q is not a non-negative integer", value) + } + } + number, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("invalid number %q: %w", value, err) + } + return number, nil +} + +func expandHashedCronField(field string, fieldIndex int, seed string) (string, error) { + terms := strings.Split(field, ",") + hashed := false + for i, term := range terms { + if !strings.HasPrefix(term, "H") { + continue + } + hashed = true + expanded, err := expandHashedCronTerm(term, fieldIndex, seed) + if err != nil { + return "", err + } + terms[i] = expanded + } + if !hashed { + return field, nil + } + return strings.Join(terms, ","), nil +} + +func expandHashedCronTerm(term string, fieldIndex int, seed string) (string, error) { + if !strings.HasPrefix(term, "H") { + return "", fmt.Errorf("malformed hashed cron term %q", term) + } + + rest := term[1:] + explicitRange := false + lo, hi, err := cronFieldBounds(fieldIndex, false) + if err != nil { + return "", err + } + + if strings.HasPrefix(rest, "(") { + close := strings.IndexByte(rest, ')') + if close < 0 { + return "", fmt.Errorf("malformed hashed cron term %q: missing ')'", term) + } + rangeParts := strings.Split(rest[1:close], "-") + if len(rangeParts) != 2 { + return "", fmt.Errorf("malformed hashed cron term %q: expected H(lo-hi)", term) + } + lo, err = parseHashedCronNumber(rangeParts[0]) + if err != nil { + return "", fmt.Errorf("malformed hashed cron term %q: %w", term, err) + } + hi, err = parseHashedCronNumber(rangeParts[1]) + if err != nil { + return "", fmt.Errorf("malformed hashed cron term %q: %w", term, err) + } + explicitRange = true + boundLo, boundHi, err := cronFieldBounds(fieldIndex, explicitRange) + if err != nil { + return "", err + } + if lo < boundLo || hi > boundHi { + return "", fmt.Errorf("hashed cron range %q is outside %d-%d", term, boundLo, boundHi) + } + if lo > hi { + return "", fmt.Errorf("hashed cron range %q is reversed", term) + } + rest = rest[close+1:] + } else if rest != "" && !strings.HasPrefix(rest, "/") { + return "", fmt.Errorf("malformed hashed cron term %q", term) + } + + step := 0 + if rest != "" { + if !strings.HasPrefix(rest, "/") || len(rest) == 1 { + return "", fmt.Errorf("malformed hashed cron term %q: expected /step", term) + } + step, err = parseHashedCronNumber(rest[1:]) + if err != nil { + return "", fmt.Errorf("malformed hashed cron term %q: %w", term, err) + } + if step == 0 { + return "", fmt.Errorf("hashed cron step must be positive") + } + } + + hash := hashedCronValue(seed, fieldIndex) + width := hi - lo + 1 + if step == 0 || step == 1 { + return strconv.Itoa(lo + int(hash%uint64(width))), nil + } + if step > width { + return "", fmt.Errorf("hashed cron step %d exceeds range %d-%d", step, lo, hi) + } + + offset := int(hash % uint64(step)) + values := make([]string, 0, (width+step-1)/step) + for value := lo + offset; value <= hi; value += step { + values = append(values, strconv.Itoa(value)) + } + return strings.Join(values, ","), nil +} + +func validateCronHasOccurrence(schedule cron.Schedule) error { + anchor := time.Now().Add(-time.Minute) + if schedule.Next(anchor).IsZero() { + return fmt.Errorf("expression has no satisfiable occurrence") + } + return nil +} + // matchesPattern checks if a name matches any of the given patterns. // Patterns can be exact matches or glob patterns using * and **. // * matches any sequence of non-separator characters @@ -101,19 +311,47 @@ func matchesPattern(name string, patterns []string) (bool, error) { } func FromFile(name string, contents []byte) (Workflow, error) { - var wf Workflow + wf := Workflow{Name: name} - err := yaml.Unmarshal(contents, &wf) - if err != nil { + if err := yaml.Unmarshal(contents, &wf); err != nil { + return wf, err + } + if err := wf.validateSchedules(); err != nil { return wf, err } - wf.Name = name wf.Raw = string(contents) return wf, nil } +func (w Workflow) validateSchedules() error { + for i, constraint := range w.When { + isSchedule := slices.Contains(constraint.Event, string(TriggerKindSchedule)) + switch { + case isSchedule && len(constraint.Schedule) == 0: + return fmt.Errorf("when[%d]: schedule event requires schedule entries", i) + case !isSchedule && len(constraint.Schedule) > 0: + return fmt.Errorf("when[%d]: schedule entries require schedule event", i) + } + if isSchedule && (len(constraint.Branch) > 0 || + len(constraint.Tag) > 0 || + len(constraint.Types) > 0 || + len(constraint.Paths) > 0) { + return fmt.Errorf("when[%d]: schedule constraints cannot specify branch, tag, types, or paths", i) + } + for j, entry := range constraint.Schedule { + if entry.Cron == "" { + return fmt.Errorf("when[%d].schedule[%d]: cron is required", i, j) + } + if _, err := parseCron(entry.Cron, w.Name, !HasHashedCron(entry.Cron)); err != nil { + return fmt.Errorf("when[%d].schedule[%d]: invalid cron expression %q: %w", i, j, entry.Cron, err) + } + } + } + return nil +} + // if any of the constraints on a workflow is true, return true func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata, changedFiles []string) (bool, error) { // manual dispatch skips matching constraints since selection is done by the caller diff --git a/workflow/def_test.go b/workflow/def_test.go index 559aa12ec..04e613cec 100644 --- a/workflow/def_test.go +++ b/workflow/def_test.go @@ -2,6 +2,7 @@ package workflow import ( "testing" + "time" "github.com/stretchr/testify/assert" "tangled.org/core/api/tangled" @@ -630,3 +631,197 @@ func TestMatch_ManualDispatch(t *testing.T) { assert.True(t, result, "workflow %d should match a manual dispatch", i) } } + +func TestUnmarshalWorkflowWithScheduleEntries(t *testing.T) { + wf, err := FromFile("nightly.yml", []byte(` +when: + - event: schedule + schedule: + - cron: "30 2 * * *" + - cron: "0 9 * * 1-5" +`)) + assert.NoError(t, err) + assert.Equal(t, []Schedule{ + {Cron: "30 2 * * *"}, + {Cron: "0 9 * * 1-5"}, + }, wf.When[0].Schedule) +} + +func TestUnmarshalWorkflowUsesNameBeforeHashedCronValidation(t *testing.T) { + wf, err := FromFile("hashed.yml", []byte(` +when: + - event: schedule + schedule: + - cron: "0 0 31 H *" +`)) + assert.NoError(t, err) + assert.Equal(t, "hashed.yml", wf.Name) +} + +func TestUnmarshalWorkflowRejectsInvalidCronSchedule(t *testing.T) { + tests := []struct { + name string + yaml string + }{ + { + name: "schedule event without entries", + yaml: "when:\n - event: schedule\n", + }, + { + name: "schedule entry without event", + yaml: "when:\n - event: push\n branch: main\n schedule:\n - cron: \"0 9 * * *\"\n", + }, + { + name: "schedule entry without cron", + yaml: "when:\n - event: schedule\n schedule:\n - {}\n", + }, + { + name: "six field cron", + yaml: "when:\n - event: schedule\n schedule:\n - cron: \"0 0 9 * * *\"\n", + }, + { + name: "invalid cron value", + yaml: "when:\n - event: schedule\n schedule:\n - cron: \"70 9 * * *\"\n", + }, + { + name: "impossible date", + yaml: "when:\n - event: schedule\n schedule:\n - cron: \"0 0 30 2 *\"\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := FromFile("invalid.yml", []byte(test.yaml)) + assert.Error(t, err) + }) + } +} + +func TestUnmarshalWorkflowRejectsScheduleFilters(t *testing.T) { + tests := []struct { + name string + yaml string + }{ + { + name: "branch", + yaml: "when:\n - event: schedule\n branch: main\n schedule:\n - cron: \"0 9 * * *\"\n", + }, + { + name: "tag", + yaml: "when:\n - event: schedule\n tag: v*\n schedule:\n - cron: \"0 9 * * *\"\n", + }, + { + name: "types", + yaml: "when:\n - event: schedule\n types: opened\n schedule:\n - cron: \"0 9 * * *\"\n", + }, + { + name: "paths", + yaml: "when:\n - event: schedule\n paths: '**/*.go'\n schedule:\n - cron: \"0 9 * * *\"\n", + }, + { + name: "schedule in event list", + yaml: "when:\n - event: [push, schedule]\n branch: main\n schedule:\n - cron: \"0 9 * * *\"\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := FromFile("invalid.yml", []byte(test.yaml)) + assert.Error(t, err) + }) + } +} + +func TestParseCronKeepsExactExpressionsIndependentOfSeed(t *testing.T) { + after := time.Date(2026, time.August, 10, 9, 29, 0, 0, time.UTC) + first, err := ParseCron("30 5 * * 1-5", "first-seed") + assert.NoError(t, err) + second, err := ParseCron("30 5 * * 1-5", "second-seed") + assert.NoError(t, err) + assert.Equal(t, first.Next(after), second.Next(after)) +} + +func TestParseCronPreservesNamedWeekdaysAlongsideHashedTerms(t *testing.T) { + after := time.Date(2026, time.January, 1, 2, 59, 0, 0, time.UTC) + named, err := ParseCron("0 3 * * THU", "named-weekday-seed") + assert.NoError(t, err) + assert.Equal(t, time.Thursday, named.Next(after).Weekday()) + + mixed, err := ParseCron("0 3 * * THU,H", "named-weekday-seed") + assert.NoError(t, err) + assert.NotZero(t, mixed.Next(after)) +} + +func TestParseCronHashedFieldsAreStableAndBounded(t *testing.T) { + after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + first, err := ParseCron("H H H H *", "stable-seed") + assert.NoError(t, err) + second, err := ParseCron("H H H H *", "stable-seed") + assert.NoError(t, err) + + firstOccurrence := first.Next(after) + secondOccurrence := second.Next(after) + assert.Equal(t, firstOccurrence, secondOccurrence) + assert.NotZero(t, firstOccurrence) + assert.GreaterOrEqual(t, firstOccurrence.Minute(), 0) + assert.LessOrEqual(t, firstOccurrence.Minute(), 59) + assert.GreaterOrEqual(t, firstOccurrence.Hour(), 0) + assert.LessOrEqual(t, firstOccurrence.Hour(), 23) + assert.GreaterOrEqual(t, firstOccurrence.Day(), 1) + assert.LessOrEqual(t, firstOccurrence.Day(), 28) + assert.GreaterOrEqual(t, int(firstOccurrence.Month()), 1) + assert.LessOrEqual(t, int(firstOccurrence.Month()), 12) + assert.GreaterOrEqual(t, int(firstOccurrence.Weekday()), 0) + assert.LessOrEqual(t, int(firstOccurrence.Weekday()), 6) +} + +func TestParseCronHashedRangeAndStep(t *testing.T) { + after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + schedule, err := ParseCron("H(10-20)/5 * * * *", "range-step-seed") + + assert.NoError(t, err) + + occurrence := schedule.Next(after) + assert.NotZero(t, occurrence) + offset := occurrence.Minute() % 5 + for range 8 { + assert.GreaterOrEqual(t, occurrence.Minute(), 10) + assert.LessOrEqual(t, occurrence.Minute(), 20) + assert.Equal(t, offset, occurrence.Minute()%5) + occurrence = schedule.Next(occurrence) + } +} + +func TestParseCronHashedStepOneKeepsJenkinsSingleValueSemantics(t *testing.T) { + after := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + schedule, err := ParseCron("H/1 * * * *", "step-one-seed") + assert.NoError(t, err) + + first := schedule.Next(after) + second := schedule.Next(first) + assert.Equal(t, time.Hour, second.Sub(first)) +} + +func TestParseCronRejectsMalformedHashedFields(t *testing.T) { + for _, expression := range []string{ + "H(foo) * * * *", + "H(20-10) * * * *", + "H(0-60) * * * *", + "H/0 * * * *", + "H(0-5)/7 * * * *", + "5H * * * *", + } { + t.Run(expression, func(t *testing.T) { + _, err := ParseCron(expression, "malformed-seed") + assert.Error(t, err) + }) + } +} + +func TestHasHashedCron(t *testing.T) { + assert.True(t, HasHashedCron("H(0-29)/10 * * * *")) + assert.True(t, HasHashedCron("H * * * *")) + assert.True(t, HasHashedCron("0 3 * * THU,H")) + assert.False(t, HasHashedCron("0 3 * * THU")) + assert.False(t, HasHashedCron("*/5 * * * *")) +}