package runner
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"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.
func runJob(ctx context.Context, j Job, root string, logs, errs io.Writer) error {
if names := j.secrets(); len(names) > 0 {
// Refused rather than run without them: a deploy that quietly got
// no token would fail later and less clearly. Delivery waits for
// RFC 0019 rung 5, which decides where a secret may go.
return fmt.Errorf("the job asks for secrets (%s), and this runner cannot receive secrets yet", strings.Join(names, ", "))
}
for i, s := range j.Steps {
fmt.Fprintf(logs, "$ %s\n", s.label(i))
cmd := exec.CommandContext(ctx, "sh", "-e", "-c", s.Run)
// A step's working-directory replaces the job's, as in Actions;
// both are relative to the tree's root.
cmd.Dir = filepath.Join(root, filepath.FromSlash(j.WorkingDirectory))
if s.WorkingDirectory != "" {
cmd.Dir = filepath.Join(root, filepath.FromSlash(s.WorkingDirectory))
}
cmd.Env = append(os.Environ(), envList(j.Env, s.Env)...)
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
}
// envList merges job env under step env, sorted so a step's environment
// is built the same way every run.
func envList(job, step map[string]string) []string {
merged := map[string]string{}
for k, v := range job {
merged[k] = v
}
for k, v := range step {
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
}