133728e barerepo 1mo
1
// Package workflow reads .github/workflows and runs the parts barerepo can, naming the parts it cannot.
133728e barerepo 1mo
2
package workflow
133728e barerepo 1mo
4
import (
133728e barerepo 1mo
5
"fmt"
133728e barerepo 1mo
6
"sort"
133728e barerepo 1mo
7
"strings"
133728e barerepo 1mo
9
"gopkg.in/yaml.v3"
133728e barerepo 1mo
12
// Dir is where GitHub keeps them, and barerepo reads them where they already are. Chapter 15A.
133728e barerepo 1mo
13
const Dir = ".github/workflows"
133728e barerepo 1mo
15
// Context is what the push knew, which is what fills the github expressions a workflow uses.
133728e barerepo 1mo
16
type Context struct {
133728e barerepo 1mo
17
Repo string // owner/name
133728e barerepo 1mo
18
Ref string
133728e barerepo 1mo
19
SHA string
133728e barerepo 1mo
22
// RefName is the branch or tag alone, which is what github.ref_name means.
133728e barerepo 1mo
23
func (c Context) RefName() string {
133728e barerepo 1mo
24
for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
133728e barerepo 1mo
25
if name, ok := strings.CutPrefix(c.Ref, prefix); ok {
133728e barerepo 1mo
26
return name
133728e barerepo 1mo
29
return c.Ref
133728e barerepo 1mo
32
// vars are the environment GitHub sets, which barerepo sets too, so a workflow reading them works.
133728e barerepo 1mo
33
func (c Context) vars() [][2]string {
133728e barerepo 1mo
34
return [][2]string{
133728e barerepo 1mo
35
{"CI", "true"},
133728e barerepo 1mo
36
{"GITHUB_ACTIONS", "true"},
133728e barerepo 1mo
37
{"GITHUB_REPOSITORY", c.Repo},
133728e barerepo 1mo
38
{"GITHUB_REF", c.Ref},
133728e barerepo 1mo
39
{"GITHUB_REF_NAME", c.RefName()},
133728e barerepo 1mo
40
{"GITHUB_SHA", c.SHA},
133728e barerepo 1mo
41
{"GITHUB_EVENT_NAME", "push"},
133728e barerepo 1mo
42
{"GITHUB_WORKFLOW", ""},
133728e barerepo 1mo
43
{"GITHUB_JOB", ""},
133728e barerepo 1mo
47
// substitutions maps the expressions barerepo knows onto the shell that answers them.
133728e barerepo 1mo
48
func substitutions() map[string]string {
133728e barerepo 1mo
49
return map[string]string{
133728e barerepo 1mo
50
"github.repository": `"$GITHUB_REPOSITORY"`,
133728e barerepo 1mo
51
"github.ref": `"$GITHUB_REF"`,
133728e barerepo 1mo
52
"github.ref_name": `"$GITHUB_REF_NAME"`,
133728e barerepo 1mo
53
"github.sha": `"$GITHUB_SHA"`,
133728e barerepo 1mo
54
"github.event_name": `"$GITHUB_EVENT_NAME"`,
133728e barerepo 1mo
55
"github.workspace": `"$PWD"`,
133728e barerepo 1mo
56
"github.workflow": `"$GITHUB_WORKFLOW"`,
133728e barerepo 1mo
57
"github.job": `"$GITHUB_JOB"`,
133728e barerepo 1mo
58
"runner.os": `"$RUNNER_OS"`,
133728e barerepo 1mo
59
"runner.arch": `"$RUNNER_ARCH"`,
133728e barerepo 1mo
60
"runner.temp": `"$RUNNER_TEMP"`,
133728e barerepo 1mo
64
// substitute fills the expressions barerepo knows and reports the ones it does not.
133728e barerepo 1mo
65
func substitute(run string) (string, []string) {
133728e barerepo 1mo
66
known := substitutions()
133728e barerepo 1mo
67
var unknown []string
133728e barerepo 1mo
68
var b strings.Builder
133728e barerepo 1mo
69
rest := run
133728e barerepo 1mo
70
for {
133728e barerepo 1mo
71
before, after, found := strings.Cut(rest, "${{")
133728e barerepo 1mo
72
if !found {
133728e barerepo 1mo
73
b.WriteString(rest)
133728e barerepo 1mo
74
return b.String(), unknown
133728e barerepo 1mo
76
b.WriteString(before)
133728e barerepo 1mo
77
inner, tail, closed := strings.Cut(after, "}}")
133728e barerepo 1mo
78
if !closed {
133728e barerepo 1mo
79
// An unclosed expression is left exactly as written, since guessing at it is worse.
133728e barerepo 1mo
80
b.WriteString("${{")
133728e barerepo 1mo
81
b.WriteString(after)
133728e barerepo 1mo
82
return b.String(), unknown
133728e barerepo 1mo
84
name := strings.TrimSpace(inner)
133728e barerepo 1mo
85
if shell, ok := known[name]; ok {
133728e barerepo 1mo
86
b.WriteString(shell)
133728e barerepo 1mo
87
} else {
133728e barerepo 1mo
88
b.WriteString("${{" + inner + "}}")
133728e barerepo 1mo
89
unknown = append(unknown, "${{"+name+"}}")
133728e barerepo 1mo
91
rest = tail
133728e barerepo 1mo
95
// Job is one workflow job reduced to what a barerepo runner needs: a machine, an image and a script.
133728e barerepo 1mo
96
type Job struct {
133728e barerepo 1mo
97
// Workflow is the name in the file, and Path is the file, so a run can say which one it is.
133728e barerepo 1mo
98
Workflow string
133728e barerepo 1mo
99
Path string
133728e barerepo 1mo
100
Name string
133728e barerepo 1mo
101
ID string
133728e barerepo 1mo
102
// RunsOn is what the job asked for, kept verbatim, because it is matched against runner labels.
133728e barerepo 1mo
103
RunsOn []string
133728e barerepo 1mo
104
Image string
133728e barerepo 1mo
105
Script string
133728e barerepo 1mo
106
// Needs names jobs that must finish first, which barerepo does not order. Reported, never silent.
133728e barerepo 1mo
107
Needs []string
133728e barerepo 1mo
108
// Skipped is every step barerepo could not translate, so a green build never means less than it says.
133728e barerepo 1mo
109
Skipped []Skip
133728e barerepo 1mo
112
// Skip is one thing barerepo did not do, and why, because a quiet omission is a lie about the build.
133728e barerepo 1mo
113
type Skip struct {
133728e barerepo 1mo
114
Step string
133728e barerepo 1mo
115
Reason string
133728e barerepo 1mo
118
// Runnable reports whether anything is left to run after the skipping.
133728e barerepo 1mo
119
func (j Job) Runnable() bool { return strings.TrimSpace(j.Script) != "" }
133728e barerepo 1mo
121
// file is the shape of a workflow yaml, holding only the keys barerepo reads.
133728e barerepo 1mo
122
type file struct {
133728e barerepo 1mo
123
Name string `yaml:"name"`
133728e barerepo 1mo
124
Env map[string]any `yaml:"env"`
133728e barerepo 1mo
125
Jobs map[string]job `yaml:"jobs"`
133728e barerepo 1mo
128
type job struct {
133728e barerepo 1mo
129
Name string `yaml:"name"`
133728e barerepo 1mo
130
RunsOn any `yaml:"runs-on"`
133728e barerepo 1mo
131
Needs any `yaml:"needs"`
133728e barerepo 1mo
132
If string `yaml:"if"`
133728e barerepo 1mo
133
Env map[string]any `yaml:"env"`
133728e barerepo 1mo
134
Container any `yaml:"container"`
133728e barerepo 1mo
135
Strategy *strategy `yaml:"strategy"`
133728e barerepo 1mo
136
Steps []step `yaml:"steps"`
133728e barerepo 1mo
139
type strategy struct {
133728e barerepo 1mo
140
Matrix map[string]any `yaml:"matrix"`
133728e barerepo 1mo
143
type step struct {
133728e barerepo 1mo
144
Name string `yaml:"name"`
133728e barerepo 1mo
145
Uses string `yaml:"uses"`
133728e barerepo 1mo
146
Run string `yaml:"run"`
133728e barerepo 1mo
147
If string `yaml:"if"`
133728e barerepo 1mo
148
Env map[string]any `yaml:"env"`
133728e barerepo 1mo
149
With map[string]any `yaml:"with"`
133728e barerepo 1mo
150
// Shell is honoured only where it is a shell barerepo can start.
133728e barerepo 1mo
151
Shell string `yaml:"shell"`
133728e barerepo 1mo
152
WorkingDirectory string `yaml:"working-directory"`
133728e barerepo 1mo
155
// Parse reads one workflow file. A file barerepo cannot parse is reported, not guessed at.
133728e barerepo 1mo
156
func Parse(path, body string, c Context) ([]Job, error) {
133728e barerepo 1mo
157
var f file
133728e barerepo 1mo
158
if err := yaml.Unmarshal([]byte(body), &f); err != nil {
133728e barerepo 1mo
159
return nil, fmt.Errorf("%s is not yaml barerepo can read: %w", path, err)
133728e barerepo 1mo
160
}
133728e barerepo 1mo
161
if len(f.Jobs) == 0 {
133728e barerepo 1mo
162
return nil, nil
133728e barerepo 1mo
163
}
133728e barerepo 1mo
164
// A map has no order, so the ids are sorted to keep two reads of one file identical.
133728e barerepo 1mo
165
ids := make([]string, 0, len(f.Jobs))
133728e barerepo 1mo
166
for id := range f.Jobs {
133728e barerepo 1mo
167
ids = append(ids, id)
133728e barerepo 1mo
168
}
133728e barerepo 1mo
169
sort.Strings(ids)
133728e barerepo 1mo
171
out := make([]Job, 0, len(ids))
133728e barerepo 1mo
172
for _, id := range ids {
133728e barerepo 1mo
173
j := f.Jobs[id]
133728e barerepo 1mo
174
combos, notes := matrixOf(j)
133728e barerepo 1mo
175
if len(combos) == 0 {
133728e barerepo 1mo
176
// Nothing to build, and a job that runs with ${{ matrix.os }} still in it builds nonsense.
133728e barerepo 1mo
177
skipped := make([]Skip, 0, len(notes))
133728e barerepo 1mo
178
for _, n := range notes {
133728e barerepo 1mo
179
skipped = append(skipped, Skip{Step: "job " + id, Reason: n})
133728e barerepo 1mo
180
}
133728e barerepo 1mo
181
out = append(out, Job{Workflow: workflowName(f, path), ID: id, Name: j.Name,
133728e barerepo 1mo
182
Path: path, Skipped: skipped})
133728e barerepo 1mo
183
continue
133728e barerepo 1mo
184
}
133728e barerepo 1mo
185
for _, m := range combos {
133728e barerepo 1mo
186
built := convert(path, f, id, j, c, m)
133728e barerepo 1mo
187
for _, n := range notes {
133728e barerepo 1mo
188
built.Skipped = append(built.Skipped, Skip{Step: "job " + id, Reason: n})
133728e barerepo 1mo
189
}
133728e barerepo 1mo
190
out = append(out, built)
133728e barerepo 1mo
191
}
133728e barerepo 1mo
192
}
133728e barerepo 1mo
193
return out, nil
133728e barerepo 1mo
196
// maxCombos is GitHub's own ceiling on one matrix, so a file that builds here builds there too. 15A.
133728e barerepo 1mo
197
const maxCombos = 256
133728e barerepo 1mo
199
// matrixOf returns the combinations a job builds, which is one empty combination when it has no matrix.
133728e barerepo 1mo
200
func matrixOf(j job) ([]combo, []string) {
133728e barerepo 1mo
201
if j.Strategy == nil || len(j.Strategy.Matrix) == 0 {
133728e barerepo 1mo
202
return []combo{{}}, nil
133728e barerepo 1mo
203
}
133728e barerepo 1mo
204
// Axes multiply, so eight of them name millions, and the file is read in a push. Counted, not built.
133728e barerepo 1mo
205
if tooManyCombos(j.Strategy.Matrix) {
133728e barerepo 1mo
206
return nil, []string{fmt.Sprintf("names more than %d combinations, and barerepo builds at most that many", maxCombos)}
133728e barerepo 1mo
207
}
133728e barerepo 1mo
208
combos, notes := expand(j.Strategy.Matrix)
133728e barerepo 1mo
209
// expand answers nil when no axis widened anything, and an empty list when exclude emptied it.
133728e barerepo 1mo
210
if combos == nil {
133728e barerepo 1mo
211
return []combo{{}}, notes
133728e barerepo 1mo
212
}
133728e barerepo 1mo
213
return combos, notes
133728e barerepo 1mo
216
// tooManyCombos multiplies the axes the way expand will, and stops at the ceiling rather than at the end.