package runner
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
"sort"
"strings"
)
// JobKey is the step metadata key a compiled workflow job travels under
// (quarry RFC 0019; dagnats#728, v0.0.21, whose jobPayload is the wire
// shape Job and JobStep read). The engine copies a step's metadata to
// the task it dispatches, so a job's commands reach the runner with the
// task and no ci/runner.json is read for it.
const JobKey = "ci.job"
// Job is one workflow job: its steps run in order, in the check-in's
// tree, and the first that fails ends it.
type Job struct {
Name string `json:"name,omitempty"`
Env map[string]string `json:"env,omitempty"`
WorkingDirectory string `json:"working-directory,omitempty"`
Environment string `json:"environment,omitempty"`
// Secrets names the secrets every step gets, never their values,
// each exported under its own name; delivering them is RFC 0019
// rung 5.
Secrets []string `json:"secrets,omitempty"`
Steps []JobStep `json:"steps"`
}
// JobStep is one `run:` of a job.
type JobStep struct {
Name string `json:"name,omitempty"`
Run string `json:"run"`
Env map[string]string `json:"env,omitempty"`
WorkingDirectory string `json:"working-directory,omitempty"`
// Secrets names the secrets this step alone gets, as in Actions.
Secrets []string `json:"secrets,omitempty"`
}
// secrets lists every secret the job asks for, the job's and its steps'.
func (j Job) secrets() []string {
seen := map[string]bool{}
var out []string
for _, n := range j.Secrets {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
for _, s := range j.Steps {
for _, n := range s.Secrets {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
}
return out
}
// ParseJob reads a job from its metadata value, refusing one that could
// not run as written: no steps, a step with nothing to run, or a working
// directory that leaves the tree.
func ParseJob(raw string) (Job, error) {
var j Job
if err := json.Unmarshal([]byte(raw), &j); err != nil {
return Job{}, fmt.Errorf("the task's job is not valid JSON: %v", err)
}
if len(j.Steps) == 0 {
return Job{}, errors.New("the task's job has no steps")
}
if err := localDir(j.WorkingDirectory); err != nil {
return Job{}, fmt.Errorf("the job's %v", err)
}
for i, s := range j.Steps {
if strings.TrimSpace(s.Run) == "" {
return Job{}, fmt.Errorf("step %d (%s) has nothing to run", i+1, s.label(i))
}
if err := localDir(s.WorkingDirectory); err != nil {
return Job{}, fmt.Errorf("step %d (%s): %v", i+1, s.label(i), err)
}
}
return j, nil
}
func localDir(d string) error {
if d != "" && !filepath.IsLocal(filepath.FromSlash(d)) {
return fmt.Errorf("working-directory %q leaves the tree", d)
}
return nil
}
// label names a step in the log: its name, or the first line of its run.
func (s JobStep) label(i int) string {
if s.Name != "" {
return s.Name
}
first, _, _ := strings.Cut(strings.TrimSpace(s.Run), "\n")
if first == "" {
return fmt.Sprintf("step %d", i+1)
}
return first
}
// runJob runs a job's steps in the tree at root. Each step is a shell
// script run with `sh -e`, as Actions runs `run:` with `-e`: the first
// failing command ends the step, and the first failing step ends the job.
// run is what the run itself tells every step (QUARRY_TAG); the job's
// and the step's env come after it.
func runJob(ctx context.Context, j Job, root string, run, secrets map[string]string, start starter, logs, errs io.Writer) error {
for i, s := range j.Steps {
fmt.Fprintf(logs, "$ %s\n", s.label(i))
// A step's working-directory replaces the job's, as in Actions;
// both are relative to the tree's root.
dir := filepath.Join(root, filepath.FromSlash(j.WorkingDirectory))
if s.WorkingDirectory != "" {
dir = filepath.Join(root, filepath.FromSlash(s.WorkingDirectory))
}
// Secrets as Actions scopes them: the job's to every step, a
// step's to that step alone, each under its own name.
cmd := start(ctx, dir, []string{"sh", "-e", "-c", s.Run}, run, j.Env, s.Env, pick(secrets, j.Secrets), pick(secrets, s.Secrets))
cmd.Stdout, cmd.Stderr = logs, errs
if err := cmd.Run(); err != nil {
return fmt.Errorf("step %d (%s): %w", i+1, s.label(i), err)
}
}
return nil
}
func pick(values map[string]string, names []string) map[string]string {
out := make(map[string]string, len(names))
for _, n := range names {
if v, ok := values[n]; ok {
out[n] = v
}
}
return out
}
// envList layers env maps (later wins), sorted so a command's
// environment is built the same way every run.
func envList(maps ...map[string]string) []string {
merged := map[string]string{}
for _, m := range maps {
for k, v := range m {
merged[k] = v
}
}
keys := make([]string, 0, len(merged))
for k := range merged {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]string, 0, len(keys))
for _, k := range keys {
out = append(out, k+"="+merged[k])
}
return out
}