Monorepo for Tangled tangled.org

workflow: outline basic workflow format

in order to work around the limitations of not having github actions'
marketplace, this approach opts to use nixpkgs as a source for packages.
alternate registries can be specified too, these are expected to be nix
flakes that expose packages.

this takes a page out of replit's approach to supplying packages to
their devshells, however, instead of using nix syntax, we use only a
flake + package combo, and the compiler will simply convert this into a
step like so:

nix profile install flake#package

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li f5df409b ac125af6

verified
Changed files
+311
workflow
+195
workflow/def.go
···
··· 1 + package workflow 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "slices" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "gopkg.in/yaml.v3" 12 + ) 13 + 14 + // - when a repo is modified, it results in the trigger of a "Pipeline" 15 + // - a repo could consist of several workflow files 16 + // * .tangled/workflows/test.yml 17 + // * .tangled/workflows/lint.yml 18 + // - therefore a pipeline consists of several workflows, these execute in parallel 19 + // - each workflow consists of some execution steps, these execute serially 20 + 21 + type ( 22 + Pipeline []Workflow 23 + 24 + // this is simply a structural representation of the workflow file 25 + Workflow struct { 26 + Name string `yaml:"-"` // name of the workflow file 27 + When []Constraint `yaml:"when"` 28 + Dependencies Dependencies `yaml:"dependencies"` 29 + Steps []Step `yaml:"steps"` 30 + Environment map[string]string `yaml:"environment"` 31 + CloneOpts CloneOpts `yaml:"clone"` 32 + } 33 + 34 + Constraint struct { 35 + Event StringList `yaml:"event"` 36 + Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 37 + } 38 + 39 + Dependencies map[string][]string 40 + 41 + CloneOpts struct { 42 + Skip bool `yaml:"skip"` 43 + Depth int `yaml:"depth"` 44 + IncludeSubmodules bool `yaml:"submodules"` 45 + } 46 + 47 + Step struct { 48 + Name string `yaml:"name"` 49 + Command string `yaml:"command"` 50 + } 51 + 52 + StringList []string 53 + ) 54 + 55 + const ( 56 + TriggerKindPush string = "push" 57 + TriggerKindPullRequest string = "pull_request" 58 + TriggerKindManual string = "manual" 59 + ) 60 + 61 + func FromFile(name string, contents []byte) (Workflow, error) { 62 + var wf Workflow 63 + 64 + err := yaml.Unmarshal(contents, &wf) 65 + if err != nil { 66 + return wf, err 67 + } 68 + 69 + wf.Name = name 70 + 71 + return wf, nil 72 + } 73 + 74 + // if any of the constraints on a workflow is true, return true 75 + func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 76 + // manual triggers always run the workflow 77 + if trigger.Manual != nil { 78 + return true 79 + } 80 + 81 + // if not manual, run through the constraint list and see if any one matches 82 + for _, c := range w.When { 83 + if c.Match(trigger) { 84 + return true 85 + } 86 + } 87 + 88 + // no constraints, always run this workflow 89 + if len(w.When) == 0 { 90 + return true 91 + } 92 + 93 + return false 94 + } 95 + 96 + func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 97 + match := true 98 + 99 + // manual triggers always pass this constraint 100 + if trigger.Manual != nil { 101 + return true 102 + } 103 + 104 + // apply event constraints 105 + match = match && c.MatchEvent(trigger.Kind) 106 + 107 + // apply branch constraints for PRs 108 + if trigger.PullRequest != nil { 109 + match = match && c.MatchBranch(trigger.PullRequest.TargetBranch) 110 + } 111 + 112 + // apply ref constraints for pushes 113 + if trigger.Push != nil { 114 + match = match && c.MatchRef(trigger.Push.Ref) 115 + } 116 + 117 + return match 118 + } 119 + 120 + func (c *Constraint) MatchBranch(branch string) bool { 121 + return slices.Contains(c.Branch, branch) 122 + } 123 + 124 + func (c *Constraint) MatchRef(ref string) bool { 125 + refName := plumbing.ReferenceName(ref) 126 + if refName.IsBranch() { 127 + return slices.Contains(c.Branch, refName.Short()) 128 + } 129 + fmt.Println("no", c.Branch, refName.Short()) 130 + 131 + return false 132 + } 133 + 134 + func (c *Constraint) MatchEvent(event string) bool { 135 + return slices.Contains(c.Event, event) 136 + } 137 + 138 + // Custom unmarshaller for StringList 139 + func (s *StringList) UnmarshalYAML(unmarshal func(any) error) error { 140 + var stringType string 141 + if err := unmarshal(&stringType); err == nil { 142 + *s = []string{stringType} 143 + return nil 144 + } 145 + 146 + var sliceType []any 147 + if err := unmarshal(&sliceType); err == nil { 148 + 149 + if sliceType == nil { 150 + *s = nil 151 + return nil 152 + } 153 + 154 + parts := make([]string, len(sliceType)) 155 + for k, v := range sliceType { 156 + if sv, ok := v.(string); ok { 157 + parts[k] = sv 158 + } else { 159 + return fmt.Errorf("cannot unmarshal '%v' of type %T into a string value", v, v) 160 + } 161 + } 162 + 163 + *s = parts 164 + return nil 165 + } 166 + 167 + return errors.New("failed to unmarshal StringOrSlice") 168 + } 169 + 170 + // conversion utilities to atproto records 171 + func (d Dependencies) AsRecord() []tangled.Pipeline_Dependencies_Elem { 172 + var deps []tangled.Pipeline_Dependencies_Elem 173 + for registry, packages := range d { 174 + deps = append(deps, tangled.Pipeline_Dependencies_Elem{ 175 + Registry: registry, 176 + Packages: packages, 177 + }) 178 + } 179 + return deps 180 + } 181 + 182 + func (s Step) AsRecord() tangled.Pipeline_Step { 183 + return tangled.Pipeline_Step{ 184 + Command: s.Command, 185 + Name: s.Name, 186 + } 187 + } 188 + 189 + func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts { 190 + return tangled.Pipeline_CloneOpts{ 191 + Depth: int64(c.Depth), 192 + Skip: c.Skip, 193 + Submodules: c.IncludeSubmodules, 194 + } 195 + }
+116
workflow/def_test.go
···
··· 1 + package workflow 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestUnmarshalWorkflow(t *testing.T) { 10 + yamlData := ` 11 + when: 12 + - event: ["push", "pull_request"] 13 + branch: ["main", "develop"] 14 + 15 + dependencies: 16 + nixpkgs: 17 + - go 18 + - git 19 + - curl 20 + 21 + steps: 22 + - name: "Test" 23 + command: | 24 + go test ./...` 25 + 26 + wf, err := FromFile("test.yml", []byte(yamlData)) 27 + assert.NoError(t, err, "YAML should unmarshal without error") 28 + 29 + assert.Len(t, wf.When, 1, "Should have one constraint") 30 + assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 + assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 + 33 + assert.Len(t, wf.Steps, 1) 34 + assert.Equal(t, "Test", wf.Steps[0].Name) 35 + assert.Equal(t, "go test ./...", wf.Steps[0].Command) 36 + 37 + pkgs, ok := wf.Dependencies["nixpkgs"] 38 + assert.True(t, ok, "`nixpkgs` should be present in dependencies") 39 + assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs) 40 + 41 + assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 + } 43 + 44 + func TestUnmarshalCustomRegistry(t *testing.T) { 45 + yamlData := ` 46 + when: 47 + - event: push 48 + branch: main 49 + 50 + dependencies: 51 + git+https://tangled.sh/@oppi.li/tbsp: 52 + - tbsp 53 + git+https://git.peppe.rs/languages/statix: 54 + - statix 55 + 56 + steps: 57 + - name: "Check" 58 + command: | 59 + statix check` 60 + 61 + wf, err := FromFile("test.yml", []byte(yamlData)) 62 + assert.NoError(t, err, "YAML should unmarshal without error") 63 + 64 + assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 65 + assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch) 66 + 67 + assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"]) 68 + assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"]) 69 + } 70 + 71 + func TestUnmarshalCloneFalse(t *testing.T) { 72 + yamlData := ` 73 + when: 74 + - event: pull_request_close 75 + 76 + clone: 77 + skip: true 78 + 79 + dependencies: 80 + nixpkgs: 81 + - python3 82 + 83 + steps: 84 + - name: Notify 85 + command: | 86 + python3 ./notify.py 87 + ` 88 + 89 + wf, err := FromFile("test.yml", []byte(yamlData)) 90 + assert.NoError(t, err) 91 + 92 + assert.ElementsMatch(t, []string{"pull_request_close"}, wf.When[0].Event) 93 + 94 + assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 + } 96 + 97 + func TestUnmarshalEnv(t *testing.T) { 98 + yamlData := ` 99 + when: 100 + - event: ["pull_request_close"] 101 + 102 + clone: 103 + skip: false 104 + 105 + environment: 106 + HOME: /home/foo bar/baz 107 + CGO_ENABLED: 1 108 + ` 109 + 110 + wf, err := FromFile("test.yml", []byte(yamlData)) 111 + assert.NoError(t, err) 112 + 113 + assert.Len(t, wf.Environment, 2) 114 + assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"]) 115 + assert.Equal(t, "1", wf.Environment["CGO_ENABLED"]) 116 + }