forked from tangled.org/core
this repo has no description

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 b361a7a5 a4794cdf

verified
Changed files
+311
workflow
+195
workflow/def.go
···
+
package workflow
+
+
import (
+
"errors"
+
"fmt"
+
"slices"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
+
"github.com/go-git/go-git/v5/plumbing"
+
"gopkg.in/yaml.v3"
+
)
+
+
// - when a repo is modified, it results in the trigger of a "Pipeline"
+
// - a repo could consist of several workflow files
+
// * .tangled/workflows/test.yml
+
// * .tangled/workflows/lint.yml
+
// - therefore a pipeline consists of several workflows, these execute in parallel
+
// - each workflow consists of some execution steps, these execute serially
+
+
type (
+
Pipeline []Workflow
+
+
// this is simply a structural representation of the workflow file
+
Workflow struct {
+
Name string `yaml:"-"` // name of the workflow file
+
When []Constraint `yaml:"when"`
+
Dependencies Dependencies `yaml:"dependencies"`
+
Steps []Step `yaml:"steps"`
+
Environment map[string]string `yaml:"environment"`
+
CloneOpts CloneOpts `yaml:"clone"`
+
}
+
+
Constraint struct {
+
Event StringList `yaml:"event"`
+
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
+
}
+
+
Dependencies map[string][]string
+
+
CloneOpts struct {
+
Skip bool `yaml:"skip"`
+
Depth int `yaml:"depth"`
+
IncludeSubmodules bool `yaml:"submodules"`
+
}
+
+
Step struct {
+
Name string `yaml:"name"`
+
Command string `yaml:"command"`
+
}
+
+
StringList []string
+
)
+
+
const (
+
TriggerKindPush string = "push"
+
TriggerKindPullRequest string = "pull_request"
+
TriggerKindManual string = "manual"
+
)
+
+
func FromFile(name string, contents []byte) (Workflow, error) {
+
var wf Workflow
+
+
err := yaml.Unmarshal(contents, &wf)
+
if err != nil {
+
return wf, err
+
}
+
+
wf.Name = name
+
+
return wf, nil
+
}
+
+
// if any of the constraints on a workflow is true, return true
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
+
// manual triggers always run the workflow
+
if trigger.Manual != nil {
+
return true
+
}
+
+
// if not manual, run through the constraint list and see if any one matches
+
for _, c := range w.When {
+
if c.Match(trigger) {
+
return true
+
}
+
}
+
+
// no constraints, always run this workflow
+
if len(w.When) == 0 {
+
return true
+
}
+
+
return false
+
}
+
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
+
match := true
+
+
// manual triggers always pass this constraint
+
if trigger.Manual != nil {
+
return true
+
}
+
+
// apply event constraints
+
match = match && c.MatchEvent(trigger.Kind)
+
+
// apply branch constraints for PRs
+
if trigger.PullRequest != nil {
+
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
+
}
+
+
// apply ref constraints for pushes
+
if trigger.Push != nil {
+
match = match && c.MatchRef(trigger.Push.Ref)
+
}
+
+
return match
+
}
+
+
func (c *Constraint) MatchBranch(branch string) bool {
+
return slices.Contains(c.Branch, branch)
+
}
+
+
func (c *Constraint) MatchRef(ref string) bool {
+
refName := plumbing.ReferenceName(ref)
+
if refName.IsBranch() {
+
return slices.Contains(c.Branch, refName.Short())
+
}
+
fmt.Println("no", c.Branch, refName.Short())
+
+
return false
+
}
+
+
func (c *Constraint) MatchEvent(event string) bool {
+
return slices.Contains(c.Event, event)
+
}
+
+
// Custom unmarshaller for StringList
+
func (s *StringList) UnmarshalYAML(unmarshal func(any) error) error {
+
var stringType string
+
if err := unmarshal(&stringType); err == nil {
+
*s = []string{stringType}
+
return nil
+
}
+
+
var sliceType []any
+
if err := unmarshal(&sliceType); err == nil {
+
+
if sliceType == nil {
+
*s = nil
+
return nil
+
}
+
+
parts := make([]string, len(sliceType))
+
for k, v := range sliceType {
+
if sv, ok := v.(string); ok {
+
parts[k] = sv
+
} else {
+
return fmt.Errorf("cannot unmarshal '%v' of type %T into a string value", v, v)
+
}
+
}
+
+
*s = parts
+
return nil
+
}
+
+
return errors.New("failed to unmarshal StringOrSlice")
+
}
+
+
// conversion utilities to atproto records
+
func (d Dependencies) AsRecord() []tangled.Pipeline_Dependencies_Elem {
+
var deps []tangled.Pipeline_Dependencies_Elem
+
for registry, packages := range d {
+
deps = append(deps, tangled.Pipeline_Dependencies_Elem{
+
Registry: registry,
+
Packages: packages,
+
})
+
}
+
return deps
+
}
+
+
func (s Step) AsRecord() tangled.Pipeline_Step {
+
return tangled.Pipeline_Step{
+
Command: s.Command,
+
Name: s.Name,
+
}
+
}
+
+
func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+
return tangled.Pipeline_CloneOpts{
+
Depth: int64(c.Depth),
+
Skip: c.Skip,
+
Submodules: c.IncludeSubmodules,
+
}
+
}
+116
workflow/def_test.go
···
+
package workflow
+
+
import (
+
"testing"
+
+
"github.com/stretchr/testify/assert"
+
)
+
+
func TestUnmarshalWorkflow(t *testing.T) {
+
yamlData := `
+
when:
+
- event: ["push", "pull_request"]
+
branch: ["main", "develop"]
+
+
dependencies:
+
nixpkgs:
+
- go
+
- git
+
- curl
+
+
steps:
+
- name: "Test"
+
command: |
+
go test ./...`
+
+
wf, err := FromFile("test.yml", []byte(yamlData))
+
assert.NoError(t, err, "YAML should unmarshal without error")
+
+
assert.Len(t, wf.When, 1, "Should have one constraint")
+
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
+
assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event)
+
+
assert.Len(t, wf.Steps, 1)
+
assert.Equal(t, "Test", wf.Steps[0].Name)
+
assert.Equal(t, "go test ./...", wf.Steps[0].Command)
+
+
pkgs, ok := wf.Dependencies["nixpkgs"]
+
assert.True(t, ok, "`nixpkgs` should be present in dependencies")
+
assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs)
+
+
assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
+
}
+
+
func TestUnmarshalCustomRegistry(t *testing.T) {
+
yamlData := `
+
when:
+
- event: push
+
branch: main
+
+
dependencies:
+
git+https://tangled.sh/@oppi.li/tbsp:
+
- tbsp
+
git+https://git.peppe.rs/languages/statix:
+
- statix
+
+
steps:
+
- name: "Check"
+
command: |
+
statix check`
+
+
wf, err := FromFile("test.yml", []byte(yamlData))
+
assert.NoError(t, err, "YAML should unmarshal without error")
+
+
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
+
assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch)
+
+
assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"])
+
assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"])
+
}
+
+
func TestUnmarshalCloneFalse(t *testing.T) {
+
yamlData := `
+
when:
+
- event: pull_request_close
+
+
clone:
+
skip: true
+
+
dependencies:
+
nixpkgs:
+
- python3
+
+
steps:
+
- name: Notify
+
command: |
+
python3 ./notify.py
+
`
+
+
wf, err := FromFile("test.yml", []byte(yamlData))
+
assert.NoError(t, err)
+
+
assert.ElementsMatch(t, []string{"pull_request_close"}, wf.When[0].Event)
+
+
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
+
}
+
+
func TestUnmarshalEnv(t *testing.T) {
+
yamlData := `
+
when:
+
- event: ["pull_request_close"]
+
+
clone:
+
skip: false
+
+
environment:
+
HOME: /home/foo bar/baz
+
CGO_ENABLED: 1
+
`
+
+
wf, err := FromFile("test.yml", []byte(yamlData))
+
assert.NoError(t, err)
+
+
assert.Len(t, wf.Environment, 2)
+
assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"])
+
assert.Equal(t, "1", wf.Environment["CGO_ENABLED"])
+
}