+14,
-0
1@@ -0,0 +1,14 @@
2+version: "2"
3+linters:
4+ settings:
5+ errcheck:
6+ # Terminal output and deferred closes of read-only files: nothing useful
7+ # to do with the error.
8+ exclude-functions:
9+ - fmt.Fprint
10+ - fmt.Fprintf
11+ - fmt.Fprintln
12+ - (*os.File).Close
13+ - (*os.File).Write
14+ - (io.Closer).Close
15+ - (*git.kilimanjaro.io/ci/internal/runlog.Writer).Close
+10,
-0
1@@ -0,0 +1,10 @@
2+# This repo's own CI: `ci run ci` locally, or on the runner for pushes to main.
3+when:
4+ - event: [push, manual]
5+ branch: [main]
6+engine: devenv
7+steps: [vet, test, build]
8+actions:
9+ vet: { command: go vet ./... }
10+ test: { command: go test ./... }
11+ build: { command: nix build .#ci --no-link --print-out-paths }
+24,
-6
1@@ -283,9 +283,19 @@ Rules
2 3. The chosen action receives `$CI_TRIGGER_RESULT` (the result that routed to
3 it), `$CI_INPUTS`, and the shared `$CI_STATE`.
4 4. Loop guard: at most 50 transitions per run (configurable).
5-5. Final status: `success` only if every happy-path step completed; otherwise
6- `failure`, annotated with the off-path actions that ran (e.g. "rolled back
7- via rollback"). This maps onto `sh.tangled.pipeline.status`.
8+5. Final status, by how the run ends:
9+ - it runs off the end of the happy path → `success`;
10+ - a result says `next: "stop"` → `success`, or `failure` if that result is
11+ a failure (the way for an action to end a run early on purpose);
12+ - an action's `then: stop` → `success` only if the cursor is past the last
13+ step, otherwise `failure` ("stopped after rollback, before record");
14+ - a failure with no `next` → `failure`; a contract or engine error →
15+ `failure`; the loop guard → `failure`;
16+ - dismissing a declared gate reached by default routing → `cancelled`;
17+ dismissing a gate raised by a result → that result's status (`failure`
18+ stays `failure`, `success`/`warning` → `success`); an expired gate,
19+ `--until` or Ctrl-C → `cancelled`.
20+ These map onto `sh.tangled.pipeline.status` (`cancelled` and `failed`).
21 6. Gates always notify, and runs that end unsuccessfully always notify
22 (runner config, not code).
23 7. Approving a gate or running a `danger` action requires the owner's logged-in
24@@ -310,10 +320,17 @@ ci action <name> --no-follow # run one action, print result + what routin
25 ci resume <run> [--choose rollback] # answer a gate / continue a paused run
26 ci action rollback --run <run> # manual action against an earlier run's state
27 ci view <run> # render events.ndjson
28-ci trigger <repo> <wf> [--rev] # ask the bridge for a manual pipeline
29+ci runs # list local runs ($CI_RUNS_DIR, default ~/.local/state/ci/runs)
30+ci trigger <repo> <wf> [--rev] # ask the runner for a manual pipeline
31 ci wait <repo> <sha> # block until done; print failing step's logs
32 ```
33
34+Actions run as `devenv shell bash -euo pipefail -c 'cd "$CI_ACTION_DIR"; <command>'`
35+started in the **main checkout**, so the workflow's repo pins the toolchain
36+and the engine even when an action's `dir` is another repo (`../servers`).
37+`--shell bash` skips devenv, for debugging only. Exit codes: 0 success, 1
38+failure, 2 usage, 3 cancelled, 75 paused at a gate.
39+
40 A local `ci run deploy` deploys production for real, behind the same gates.
41 `--until` and `--stub` are the rehearsal tools.
42
43@@ -325,8 +342,9 @@ Environment contract (versioned with the result schema)
44 | `CI_RESULT` | Where this action writes its result |
45 | `CI_STATE` | Shared state for the run's actions |
46 | `CI_ARTIFACTS` | Attachment directory |
47-| `CI_INPUTS` | Data from the `next` entry that routed here |
48-| `CI_TRIGGER_RESULT` | The result that routed here |
49+| `CI_INPUTS` | Path of a JSON file: the `inputs` of the `next` entry that routed here (`{}` if none) |
50+| `CI_TRIGGER_RESULT` | Path of the result file that routed here (empty if none) |
51+| `CI_ROOT`, `CI_WORKFLOW`, `CI_ATTEMPT` | Main checkout; workflow name; execution number in the run |
52 | `CI_SHA`, `CI_REF`, `CI_REPO` | What's being run |
53 | `CI_RUN_ID`, `CI_ACTION` | Identity of the run and action |
54 | `CI_HOST` | `local` or `runner`. Informational; recipes must not branch on it |
+400,
-0
1@@ -0,0 +1,400 @@
2+package main
3+
4+import (
5+ "bytes"
6+ "context"
7+ "errors"
8+ "flag"
9+ "fmt"
10+ "io"
11+ "os"
12+ "os/signal"
13+ "path/filepath"
14+ "sort"
15+ "strings"
16+ "syscall"
17+ "time"
18+
19+ "git.kilimanjaro.io/ci/internal/engine"
20+ "git.kilimanjaro.io/ci/internal/lex"
21+ "git.kilimanjaro.io/ci/internal/manifest"
22+ "git.kilimanjaro.io/ci/internal/render"
23+ "git.kilimanjaro.io/ci/internal/runlog"
24+ "git.kilimanjaro.io/ci/internal/tid"
25+ "git.kilimanjaro.io/ci/internal/vcs"
26+)
27+
28+// Exit codes of run, action and resume.
29+const (
30+ exitSuccess = 0
31+ exitFailure = 1
32+ exitUsage = 2
33+ exitCancelled = 3
34+ exitPaused = 75 // EX_TEMPFAIL: waiting at a gate
35+)
36+
37+func init() {
38+ commands["run"] = command{"run a workflow from its first step", cmdRun}
39+ commands["action"] = command{"run a workflow starting at one action", cmdAction}
40+ commands["validate"] = command{"check workflow manifests", cmdValidate}
41+ commands["view"] = command{"render a run's events", cmdView}
42+ commands["runs"] = command{"list local runs", cmdRuns}
43+}
44+
45+// runsDir is where local runs live: $CI_RUNS_DIR, else
46+// $XDG_STATE_HOME/ci/runs, else ~/.local/state/ci/runs.
47+func runsDir() string {
48+ if d := os.Getenv("CI_RUNS_DIR"); d != "" {
49+ return d
50+ }
51+ if d := os.Getenv("XDG_STATE_HOME"); d != "" {
52+ return filepath.Join(d, "ci", "runs")
53+ }
54+ home, _ := os.UserHomeDir()
55+ return filepath.Join(home, ".local", "state", "ci", "runs")
56+}
57+
58+type stubFlag map[string]string
59+
60+func (s stubFlag) String() string { return "" }
61+func (s stubFlag) Set(v string) error {
62+ a, f, ok := strings.Cut(v, "=")
63+ if !ok || a == "" || f == "" {
64+ return fmt.Errorf("want <action>=<result.json>, got %q", v)
65+ }
66+ abs, err := filepath.Abs(f)
67+ if err != nil {
68+ return err
69+ }
70+ s[a] = abs
71+ return nil
72+}
73+
74+// runFlags are shared by run and action.
75+type runFlags struct {
76+ fs *flag.FlagSet
77+ stubs stubFlag
78+ until string
79+ shell string
80+ ref string
81+ maxTrans int
82+ quiet bool
83+}
84+
85+func newRunFlags(name string) *runFlags {
86+ f := &runFlags{fs: flag.NewFlagSet("ci "+name, flag.ContinueOnError), stubs: stubFlag{}}
87+ f.fs.Var(f.stubs, "stub", "use `action=result.json` instead of running the action (repeatable)")
88+ f.fs.StringVar(&f.until, "until", "", "stop before this `action` runs")
89+ f.fs.StringVar(&f.shell, "shell", "devenv", "how actions run: devenv (default) or bash (no devenv; debugging)")
90+ f.fs.StringVar(&f.ref, "ref", "", "ref to report as CI_REF/TANGLED_REF, e.g. refs/heads/main")
91+ f.fs.IntVar(&f.maxTrans, "max-transitions", 0, "loop guard (default 50)")
92+ f.fs.BoolVar(&f.quiet, "q", false, "don't render events (they are still written to the run)")
93+ return f
94+}
95+
96+// parse parses flags and positionals in any order.
97+func (f *runFlags) parse(args []string) ([]string, error) {
98+ var pos []string
99+ for {
100+ if err := f.fs.Parse(args); err != nil {
101+ return nil, err
102+ }
103+ args = f.fs.Args()
104+ if len(args) == 0 {
105+ return pos, nil
106+ }
107+ pos = append(pos, args[0])
108+ args = args[1:]
109+ }
110+}
111+
112+func (f *runFlags) executor() (engine.Executor, error) {
113+ switch f.shell {
114+ case "devenv":
115+ return engine.Devenv{}, nil
116+ case "bash":
117+ return engine.Bash{}, nil
118+ }
119+ return nil, fmt.Errorf("--shell: %q (want devenv or bash)", f.shell)
120+}
121+
122+// loadWorkflow resolves a workflow argument: a name under .tangled/workflows
123+// of the repository containing the working directory, or a file path.
124+func loadWorkflow(arg string) (*manifest.Manifest, string, error) {
125+ root, err := vcs.Root(".")
126+ if err != nil {
127+ return nil, "", err
128+ }
129+ p := arg
130+ if !strings.ContainsRune(arg, os.PathSeparator) && !strings.HasSuffix(arg, ".yml") && !strings.HasSuffix(arg, ".yaml") {
131+ if p, err = manifest.Find(root, arg); err != nil {
132+ return nil, "", err
133+ }
134+ }
135+ m, err := manifest.Load(p)
136+ return m, root, err
137+}
138+
139+func cmdRun(args []string) int { return runCommon("run", args) }
140+func cmdAction(args []string) int { return runCommon("action", args) }
141+
142+func runCommon(name string, args []string) int {
143+ f := newRunFlags(name)
144+ noFollow := false
145+ usage := "ci run [flags] <workflow>"
146+ if name == "action" {
147+ f.fs.BoolVar(&noFollow, "no-follow", false, "run only this action; report what routing would do next")
148+ usage = "ci action [flags] <workflow> <action>"
149+ }
150+ f.fs.Usage = func() {
151+ fmt.Fprintf(os.Stderr, "usage: %s\n", usage)
152+ f.fs.PrintDefaults()
153+ }
154+ pos, err := f.parse(args)
155+ if err != nil {
156+ return exitUsage
157+ }
158+ want := 1
159+ if name == "action" {
160+ want = 2
161+ }
162+ if len(pos) != want {
163+ f.fs.Usage()
164+ return exitUsage
165+ }
166+ ex, err := f.executor()
167+ if err != nil {
168+ fmt.Fprintln(os.Stderr, "ci:", err)
169+ return exitUsage
170+ }
171+ m, root, err := loadWorkflow(pos[0])
172+ if err != nil {
173+ fmt.Fprintln(os.Stderr, "ci:", err)
174+ return exitFailure
175+ }
176+ for a := range f.stubs {
177+ if _, ok := m.Actions[a]; !ok {
178+ fmt.Fprintf(os.Stderr, "ci: --stub %s: no such action in %s\n", a, m.Name)
179+ return exitUsage
180+ }
181+ }
182+ sha, dirty, err := vcs.Head(root)
183+ if err != nil {
184+ msg, _, _ := strings.Cut(err.Error(), "\n")
185+ fmt.Fprintln(os.Stderr, "ci: warning: no commit for the working copy:", msg)
186+ }
187+ id := tid.New()
188+ meta := engine.Meta{
189+ Run: id, Workflow: m.Name, Repo: filepath.Base(root), Sha: sha, Ref: f.ref,
190+ Kind: "manual", Host: "local", Root: root, Dirty: dirty,
191+ }
192+ dir := engine.Dir(filepath.Join(runsDir(), id))
193+ e, err := engine.Create(m, dir, meta, ex, engine.Options{
194+ Stubs: f.stubs, Until: f.until, NoFollow: noFollow, MaxTransitions: f.maxTrans,
195+ })
196+ if err != nil {
197+ fmt.Fprintln(os.Stderr, "ci:", err)
198+ return exitFailure
199+ }
200+ defer e.Log.Close()
201+ if !f.quiet {
202+ r := render.New(os.Stdout)
203+ // replay what Create already wrote, then render live
204+ if fh, err := os.Open(dir.Events()); err == nil {
205+ _ = runlog.Scan(fh, func(ev lex.RunEvent) error { r.Event(ev); return nil })
206+ fh.Close()
207+ }
208+ e.Log.AddSink(r.Event)
209+ }
210+
211+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
212+ defer stop()
213+ var st *engine.State
214+ if name == "action" {
215+ st, err = e.RunFrom(ctx, pos[1])
216+ } else {
217+ st, err = e.Run(ctx)
218+ }
219+ if err != nil {
220+ fmt.Fprintln(os.Stderr, "ci:", err)
221+ return exitFailure
222+ }
223+ fmt.Fprintf(os.Stderr, "run %s: %s\n", id, dir)
224+ return exitCode(st.Status)
225+}
226+
227+func exitCode(status string) int {
228+ switch status {
229+ case engine.StatusSuccess:
230+ return exitSuccess
231+ case engine.StatusCancelled:
232+ return exitCancelled
233+ case engine.StatusPaused:
234+ return exitPaused
235+ }
236+ return exitFailure
237+}
238+
239+func cmdValidate(args []string) int {
240+ fs := flag.NewFlagSet("ci validate", flag.ContinueOnError)
241+ fs.Usage = func() {
242+ fmt.Fprintln(os.Stderr, "usage: ci validate [workflow|file ...] (default: every workflow in the repo)")
243+ }
244+ if err := fs.Parse(args); err != nil {
245+ return exitUsage
246+ }
247+ paths := fs.Args()
248+ if len(paths) == 0 {
249+ root, err := vcs.Root(".")
250+ if err != nil {
251+ fmt.Fprintln(os.Stderr, "ci:", err)
252+ return exitFailure
253+ }
254+ if paths, err = manifest.List(root); err != nil {
255+ fmt.Fprintln(os.Stderr, "ci:", err)
256+ return exitFailure
257+ }
258+ if len(paths) == 0 {
259+ fmt.Fprintf(os.Stderr, "ci: no workflows in %s\n", filepath.Join(root, manifest.Dir))
260+ return exitFailure
261+ }
262+ }
263+ code := exitSuccess
264+ for _, p := range paths {
265+ m, _, err := loadWorkflow(p)
266+ switch {
267+ case errors.Is(err, manifest.ErrOtherEngine):
268+ fmt.Printf("skip %s: %v\n", p, err)
269+ case err != nil:
270+ fmt.Printf("FAIL %v\n", err)
271+ code = exitFailure
272+ default:
273+ fmt.Printf("ok %s: %s\n", m.Name, strings.Join(m.Steps, " → "))
274+ }
275+ }
276+ return code
277+}
278+
279+// runDir resolves a run argument: a run directory, a run id under runsDir,
280+// or "last".
281+func runDir(arg string) (engine.Dir, error) {
282+ if arg == "last" {
283+ runs, err := listRuns()
284+ if err != nil {
285+ return "", err
286+ }
287+ if len(runs) == 0 {
288+ return "", fmt.Errorf("no runs in %s", runsDir())
289+ }
290+ return runs[len(runs)-1], nil
291+ }
292+ for _, p := range []string{arg, filepath.Join(runsDir(), arg)} {
293+ if _, err := os.Stat(filepath.Join(p, "state.json")); err == nil {
294+ return engine.Dir(p), nil
295+ }
296+ }
297+ return "", fmt.Errorf("%w: %s", engine.ErrNoRun, arg)
298+}
299+
300+// listRuns returns run directories under runsDir, oldest first (run ids are
301+// TIDs, which sort by time).
302+func listRuns() ([]engine.Dir, error) {
303+ ents, err := os.ReadDir(runsDir())
304+ if errors.Is(err, os.ErrNotExist) {
305+ return nil, nil
306+ }
307+ if err != nil {
308+ return nil, err
309+ }
310+ var out []engine.Dir
311+ for _, e := range ents {
312+ if _, err := os.Stat(filepath.Join(runsDir(), e.Name(), "state.json")); e.IsDir() && err == nil {
313+ out = append(out, engine.Dir(filepath.Join(runsDir(), e.Name())))
314+ }
315+ }
316+ sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
317+ return out, nil
318+}
319+
320+func cmdView(args []string) int {
321+ fs := flag.NewFlagSet("ci view", flag.ContinueOnError)
322+ follow := fs.Bool("f", false, "follow a running run until it ends or pauses")
323+ fs.Usage = func() {
324+ fmt.Fprintln(os.Stderr, "usage: ci view [-f] <run-id|run-dir|last>")
325+ fs.PrintDefaults()
326+ }
327+ if err := fs.Parse(args); err != nil || fs.NArg() != 1 {
328+ fs.Usage()
329+ return exitUsage
330+ }
331+ dir, err := runDir(fs.Arg(0))
332+ if err != nil {
333+ fmt.Fprintln(os.Stderr, "ci:", err)
334+ return exitFailure
335+ }
336+ r := render.New(os.Stdout)
337+ fh, err := os.Open(dir.Events())
338+ if err != nil {
339+ fmt.Fprintln(os.Stderr, "ci:", err)
340+ return exitFailure
341+ }
342+ defer fh.Close()
343+ var off int64
344+ for {
345+ n, err := renderFrom(fh, off, r.Event)
346+ if err != nil {
347+ fmt.Fprintln(os.Stderr, "ci:", err)
348+ return exitFailure
349+ }
350+ off += n
351+ st, err := engine.Load(dir)
352+ if err != nil {
353+ fmt.Fprintln(os.Stderr, "ci:", err)
354+ return exitFailure
355+ }
356+ if !*follow || st.Status != engine.StatusRunning {
357+ return exitCode(st.Status)
358+ }
359+ time.Sleep(250 * time.Millisecond)
360+ }
361+}
362+
363+// renderFrom renders the complete lines of f after offset off and returns
364+// how many bytes they took, so `view -f` never renders a half-written line.
365+func renderFrom(f *os.File, off int64, render func(lex.RunEvent)) (int64, error) {
366+ if _, err := f.Seek(off, io.SeekStart); err != nil {
367+ return 0, err
368+ }
369+ b, err := io.ReadAll(f)
370+ if err != nil {
371+ return 0, err
372+ }
373+ end := bytes.LastIndexByte(b, '\n')
374+ if end < 0 {
375+ return 0, nil
376+ }
377+ err = runlog.Scan(bytes.NewReader(b[:end+1]), func(ev lex.RunEvent) error { render(ev); return nil })
378+ return int64(end + 1), err
379+}
380+
381+func cmdRuns(args []string) int {
382+ runs, err := listRuns()
383+ if err != nil {
384+ fmt.Fprintln(os.Stderr, "ci:", err)
385+ return exitFailure
386+ }
387+ for _, d := range runs {
388+ st, err := engine.Load(d)
389+ if err != nil {
390+ continue
391+ }
392+ detail := ""
393+ if st.End != nil {
394+ detail = st.End.Detail
395+ } else if st.Gate != nil {
396+ detail = "waiting at gate: " + st.Gate.Prompt
397+ }
398+ fmt.Printf("%s %-9s %-12s %-10s %s\n", filepath.Base(string(d)), st.Status, st.Meta.Repo, st.Meta.Workflow, detail)
399+ }
400+ return exitSuccess
401+}
+1,
-0
1@@ -36,6 +36,7 @@ func usage() {
2 }
3
4 func main() {
5+ addStubs()
6 args := os.Args[1:]
7 if filepath.Base(os.Args[0]) == "ci-result" {
8 args = append([]string{"result"}, args...)
+299,
-0
1@@ -0,0 +1,299 @@
2+package main
3+
4+import (
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "git.kilimanjaro.io/ci/internal/lex"
13+)
14+
15+func init() {
16+ commands["result"] = command{"write an action result to $CI_RESULT (alias: ci-result)", cmdResult}
17+}
18+
19+const resultUsage = `usage: ci-result <ok|warn|fail> [flags]
20+
21+Writes a run.rtw.ci.step.result to $CI_RESULT (DESIGN.md §7).
22+
23+ --summary TEXT markdown summary (repeatable; joined by newlines)
24+ --summary-file FILE read the summary from FILE ("-" for stdin)
25+ --next ACTION route to ACTION; repeat for a choice. The flags
26+ below apply to the most recent --next:
27+ --then continue|stop after ACTION succeeds
28+ --label TEXT button label
29+ --prompt TEXT question for the person
30+ --input KEY=VALUE string passed in $CI_INPUTS (repeatable)
31+ --inputs-json JSON object passed in $CI_INPUTS
32+ --stop end the run after this action
33+ --gated ask a person before taking --next
34+ --attach [LABEL=]PATH attach a file; copied into $CI_ARTIFACTS if outside
35+ it; type from the extension (.md .ndjson .json, else text)
36+ --output KEY=VALUE string merged into $CI_STATE/outputs.json (repeatable)
37+ --outputs-json JSON object merged into $CI_STATE/outputs.json
38+ --stdout print the result instead of writing $CI_RESULT
39+`
40+
41+func cmdResult(args []string) int {
42+ r, toStdout, err := parseResult(args)
43+ if err != nil {
44+ fmt.Fprintf(os.Stderr, "ci-result: %v\n\n%s", err, resultUsage)
45+ return exitUsage
46+ }
47+ b, err := json.MarshalIndent(r, "", " ")
48+ if err != nil {
49+ fmt.Fprintln(os.Stderr, "ci-result:", err)
50+ return exitFailure
51+ }
52+ b = append(b, '\n')
53+ if toStdout {
54+ os.Stdout.Write(b)
55+ return exitSuccess
56+ }
57+ path := os.Getenv("CI_RESULT")
58+ if path == "" {
59+ fmt.Fprintln(os.Stderr, "ci-result: $CI_RESULT is not set (not inside a ci action? use --stdout)")
60+ return exitFailure
61+ }
62+ if err := os.WriteFile(path, b, 0o644); err != nil {
63+ fmt.Fprintln(os.Stderr, "ci-result:", err)
64+ return exitFailure
65+ }
66+ return exitSuccess
67+}
68+
69+func parseResult(args []string) (lex.Result, bool, error) {
70+ r := lex.Result{Version: lex.ResultVersion}
71+ if len(args) == 0 {
72+ return r, false, fmt.Errorf("missing status")
73+ }
74+ switch args[0] {
75+ case "ok", "success":
76+ r.Status = lex.Success
77+ case "warn", "warning":
78+ r.Status = lex.Warning
79+ case "fail", "failure":
80+ r.Status = lex.Failure
81+ case "-h", "--help", "help":
82+ return r, false, fmt.Errorf("help")
83+ default:
84+ return r, false, fmt.Errorf("status %q (want ok, warn or fail)", args[0])
85+ }
86+ args = args[1:]
87+
88+ var (
89+ summary []string
90+ toStdout bool
91+ stop bool
92+ opts []lex.Option
93+ inputs []map[string]any
94+ outputs = map[string]any{}
95+ )
96+ value := func(i int, flag string) (string, error) {
97+ if i+1 >= len(args) {
98+ return "", fmt.Errorf("%s needs a value", flag)
99+ }
100+ return args[i+1], nil
101+ }
102+ lastOpt := func(flag string) (*lex.Option, error) {
103+ if len(opts) == 0 {
104+ return nil, fmt.Errorf("%s must follow --next", flag)
105+ }
106+ return &opts[len(opts)-1], nil
107+ }
108+ for i := 0; i < len(args); i++ {
109+ flag := args[i]
110+ if k, v, ok := strings.Cut(flag, "="); ok && strings.HasPrefix(k, "--") {
111+ args = append(args[:i], append([]string{k, v}, args[i+1:]...)...)
112+ flag = k
113+ }
114+ switch flag {
115+ case "--stdout":
116+ toStdout = true
117+ continue
118+ case "--stop":
119+ stop = true
120+ continue
121+ case "--gated":
122+ r.Gated = true
123+ continue
124+ }
125+ v, err := value(i, flag)
126+ if err != nil {
127+ return r, false, err
128+ }
129+ i++
130+ switch flag {
131+ case "--summary":
132+ summary = append(summary, v)
133+ case "--summary-file":
134+ var b []byte
135+ if v == "-" {
136+ b, err = io.ReadAll(os.Stdin)
137+ } else {
138+ b, err = os.ReadFile(v)
139+ }
140+ if err != nil {
141+ return r, false, err
142+ }
143+ summary = append(summary, strings.TrimRight(string(b), "\n"))
144+ case "--next":
145+ opts = append(opts, lex.Option{Action: v})
146+ inputs = append(inputs, nil)
147+ case "--then":
148+ o, err := lastOpt(flag)
149+ if err != nil {
150+ return r, false, err
151+ }
152+ if v != lex.ThenContinue && v != lex.ThenStop {
153+ return r, false, fmt.Errorf("--then %q (want continue or stop)", v)
154+ }
155+ o.Then = v
156+ case "--label":
157+ o, err := lastOpt(flag)
158+ if err != nil {
159+ return r, false, err
160+ }
161+ o.Label = v
162+ case "--prompt":
163+ o, err := lastOpt(flag)
164+ if err != nil {
165+ return r, false, err
166+ }
167+ o.Prompt = v
168+ case "--input":
169+ if _, err := lastOpt(flag); err != nil {
170+ return r, false, err
171+ }
172+ k, val, ok := strings.Cut(v, "=")
173+ if !ok || k == "" {
174+ return r, false, fmt.Errorf("--input wants KEY=VALUE, got %q", v)
175+ }
176+ if inputs[len(inputs)-1] == nil {
177+ inputs[len(inputs)-1] = map[string]any{}
178+ }
179+ inputs[len(inputs)-1][k] = val
180+ case "--inputs-json":
181+ if _, err := lastOpt(flag); err != nil {
182+ return r, false, err
183+ }
184+ var m map[string]any
185+ if err := json.Unmarshal([]byte(v), &m); err != nil {
186+ return r, false, fmt.Errorf("--inputs-json: want an object: %v", err)
187+ }
188+ if inputs[len(inputs)-1] == nil {
189+ inputs[len(inputs)-1] = map[string]any{}
190+ }
191+ for k, x := range m {
192+ inputs[len(inputs)-1][k] = x
193+ }
194+ case "--attach":
195+ a, err := attach(v)
196+ if err != nil {
197+ return r, false, err
198+ }
199+ r.Attachments = append(r.Attachments, a)
200+ case "--output":
201+ k, val, ok := strings.Cut(v, "=")
202+ if !ok || k == "" {
203+ return r, false, fmt.Errorf("--output wants KEY=VALUE, got %q", v)
204+ }
205+ outputs[k] = val
206+ case "--outputs-json":
207+ var m map[string]any
208+ if err := json.Unmarshal([]byte(v), &m); err != nil {
209+ return r, false, fmt.Errorf("--outputs-json: want an object: %v", err)
210+ }
211+ for k, x := range m {
212+ outputs[k] = x
213+ }
214+ default:
215+ return r, false, fmt.Errorf("unknown flag %q", flag)
216+ }
217+ }
218+ if stop && len(opts) > 0 {
219+ return r, false, fmt.Errorf("--stop and --next are exclusive")
220+ }
221+ r.Summary = strings.Join(summary, "\n")
222+ for i, in := range inputs {
223+ if in != nil {
224+ b, _ := json.Marshal(in)
225+ opts[i].Inputs = b
226+ }
227+ }
228+ switch {
229+ case stop:
230+ r.Next = &lex.Next{Stop: true}
231+ case len(opts) > 0:
232+ r.Next = &lex.Next{Options: opts}
233+ }
234+ if len(outputs) > 0 {
235+ b, _ := json.Marshal(outputs)
236+ r.Outputs = b
237+ }
238+ return r, toStdout, nil
239+}
240+
241+// attach turns "[LABEL=]PATH" into an attachment relative to $CI_ARTIFACTS,
242+// copying the file in when it lives elsewhere (each action has a private
243+// /tmp that disappears when it ends).
244+func attach(spec string) (lex.Attachment, error) {
245+ label, p, ok := strings.Cut(spec, "=")
246+ if !ok {
247+ label, p = "", spec
248+ }
249+ a := lex.Attachment{Label: label, Type: typeOf(p)}
250+ art := os.Getenv("CI_ARTIFACTS")
251+ if art == "" {
252+ return a, fmt.Errorf("--attach: $CI_ARTIFACTS is not set")
253+ }
254+ abs := p
255+ if !filepath.IsAbs(abs) {
256+ // relative paths are relative to $CI_ARTIFACTS if the file is there,
257+ // else to the working directory
258+ if _, err := os.Stat(filepath.Join(art, p)); err == nil {
259+ a.Path = filepath.Clean(p)
260+ return a, nil
261+ }
262+ var err error
263+ if abs, err = filepath.Abs(p); err != nil {
264+ return a, err
265+ }
266+ }
267+ if rel, err := filepath.Rel(art, abs); err == nil && !strings.HasPrefix(rel, "..") {
268+ a.Path = rel
269+ return a, nil
270+ }
271+ b, err := os.ReadFile(abs)
272+ if err != nil {
273+ return a, fmt.Errorf("--attach: %w", err)
274+ }
275+ name := filepath.Base(abs)
276+ for n := 2; ; n++ {
277+ if _, err := os.Stat(filepath.Join(art, name)); os.IsNotExist(err) {
278+ break
279+ }
280+ ext := filepath.Ext(abs)
281+ name = fmt.Sprintf("%s-%d%s", strings.TrimSuffix(filepath.Base(abs), ext), n, ext)
282+ }
283+ if err := os.WriteFile(filepath.Join(art, name), b, 0o644); err != nil {
284+ return a, err
285+ }
286+ a.Path = name
287+ return a, nil
288+}
289+
290+func typeOf(p string) string {
291+ switch strings.ToLower(filepath.Ext(p)) {
292+ case ".md", ".markdown":
293+ return "markdown"
294+ case ".ndjson", ".jsonl":
295+ return "ndjson"
296+ case ".json":
297+ return "json"
298+ }
299+ return "text"
300+}
+68,
-0
1@@ -0,0 +1,68 @@
2+package main
3+
4+import (
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "git.kilimanjaro.io/ci/internal/lex"
11+)
12+
13+func TestParseResult(t *testing.T) {
14+ art := t.TempDir()
15+ t.Setenv("CI_ARTIFACTS", art)
16+ outside := filepath.Join(t.TempDir(), "probes.ndjson")
17+ if err := os.WriteFile(outside, []byte("{}\n"), 0o644); err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := os.WriteFile(filepath.Join(art, "diff.md"), []byte("x"), 0o644); err != nil {
21+ t.Fatal(err)
22+ }
23+
24+ r, _, err := parseResult([]string{"fail",
25+ "--summary", "a: 503", "--summary=line two", "--gated",
26+ "--next", "rollback", "--then", "stop", "--prompt", "Roll back?", "--input", "host=a",
27+ "--next", "health-check", "--label", "Check again",
28+ "--attach", "probes=" + outside, "--attach", "diff.md",
29+ "--output", "k=v", "--outputs-json", `{"n":2}`,
30+ })
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ b, _ := json.Marshal(r)
35+ var back lex.Result
36+ if err := lex.StrictUnmarshal(b, &back); err != nil {
37+ t.Fatalf("%s: %v", b, err)
38+ }
39+ if r.Status != lex.Failure || r.Summary != "a: 503\nline two" || !r.Gated {
40+ t.Errorf("%+v", r)
41+ }
42+ o := r.Next.Options
43+ if len(o) != 2 || o[0].Then != "stop" || o[0].Prompt != "Roll back?" || string(o[0].Inputs) != `{"host":"a"}` || o[1].Label != "Check again" {
44+ t.Errorf("options %+v", o)
45+ }
46+ if r.Attachments[0].Path != "probes.ndjson" || r.Attachments[0].Type != "ndjson" || r.Attachments[1].Path != "diff.md" || r.Attachments[1].Type != "markdown" {
47+ t.Errorf("attachments %+v", r.Attachments)
48+ }
49+ if _, err := os.Stat(filepath.Join(art, "probes.ndjson")); err != nil {
50+ t.Error("outside attachment not copied in")
51+ }
52+ if string(r.Outputs) != `{"k":"v","n":2}` {
53+ t.Errorf("outputs %s", r.Outputs)
54+ }
55+
56+ r, _, err = parseResult([]string{"ok", "--stop"})
57+ if err != nil || !r.Next.Stop || r.Status != lex.Success {
58+ t.Errorf("--stop: %+v %v", r, err)
59+ }
60+
61+ for _, bad := range [][]string{
62+ {}, {"maybe"}, {"ok", "--then", "stop"}, {"ok", "--next"}, {"ok", "--next", "a", "--then", "later"},
63+ {"ok", "--stop", "--next", "a"}, {"ok", "--output", "novalue"}, {"ok", "--bogus", "x"},
64+ } {
65+ if _, _, err := parseResult(bad); err == nil {
66+ t.Errorf("%v: accepted", bad)
67+ }
68+ }
69+}
+10,
-13
1@@ -5,20 +5,17 @@ import (
2 "os"
3 )
4
5-// Commands not implemented yet; each issue in the tracker replaces its stub.
6-func init() {
7+// addStubs registers the commands not implemented yet; each issue in the
8+// tracker replaces its stub. Called from main after every init has run, so a
9+// real command always wins.
10+func addStubs() {
11 for name, summary := range map[string]string{
12- "run": "run a workflow (engine)",
13- "resume": "continue a paused run (engine)",
14- "action": "run one action (engine)",
15- "validate": "check a workflow manifest (engine)",
16- "view": "render a run's events (engine)",
17- "result": "write an action result to $CI_RESULT (alias: ci-result)",
18- "trigger": "ask the runner for a manual pipeline",
19- "wait": "wait for a pipeline and print failing logs",
20- "bridge": "forge bridge (knot-compatible event stream)",
21- "runner": "runner daemon (spindle-compatible)",
22- "appview": "web UI",
23+ "resume": "continue a paused run (engine)",
24+ "trigger": "ask the runner for a manual pipeline",
25+ "wait": "wait for a pipeline and print failing logs",
26+ "bridge": "forge bridge (knot-compatible event stream)",
27+ "runner": "runner daemon (spindle-compatible)",
28+ "appview": "web UI",
29 } {
30 if _, ok := commands[name]; ok {
31 continue
+1,
-1
1@@ -34,7 +34,7 @@
2 ./lexicons
3 ];
4 };
5- vendorHash = null;
6+ vendorHash = "sha256-xWBzri5Qza/1abQHZtOhnRzRjw57lOrCZOhIQqoftOE=";
7 subPackages = [ "cmd/ci" ];
8 env.CGO_ENABLED = "0";
9 ldflags = [ "-X main.version=${version}" ];
M
go.mod
+5,
-0
1@@ -1,3 +1,8 @@
2 module git.kilimanjaro.io/ci
3
4 go 1.26
5+
6+require (
7+ github.com/bmatcuk/doublestar/v4 v4.10.3 // indirect
8+ gopkg.in/yaml.v3 v3.0.1 // indirect
9+)
A
go.sum
+5,
-0
1@@ -0,0 +1,5 @@
2+github.com/bmatcuk/doublestar/v4 v4.10.3 h1:1UIv1+fqH3++ViVkpnaPTB+Pi1UHycnPFAq14zgK+gI=
3+github.com/bmatcuk/doublestar/v4 v4.10.3/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
4+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
6+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+295,
-0
1@@ -0,0 +1,295 @@
2+package engine
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+
12+ "git.kilimanjaro.io/ci/internal/lex"
13+ "git.kilimanjaro.io/ci/internal/manifest"
14+ "git.kilimanjaro.io/ci/internal/runlog"
15+)
16+
17+// DefaultMaxTransitions is the loop guard (DESIGN.md §8 rule 4).
18+const DefaultMaxTransitions = 50
19+
20+// Options change how a run executes.
21+type Options struct {
22+ // Stubs maps an action to a result file used instead of running it.
23+ Stubs map[string]string
24+ // Until stops the run before this action would run.
25+ Until string
26+ // NoFollow runs the first action only and reports what would follow.
27+ NoFollow bool
28+ // MaxTransitions overrides DefaultMaxTransitions.
29+ MaxTransitions int
30+}
31+
32+// Engine runs one workflow run.
33+type Engine struct {
34+ M *manifest.Manifest
35+ Dir Dir
36+ Exec Executor
37+ Log *runlog.Writer
38+ BaseEnv []string
39+ Opts Options
40+
41+ router *Router
42+ st *State
43+}
44+
45+// Create starts a new run in dir: it writes the manifest copy, state.json and
46+// the first event. Close the engine's Log when done.
47+func Create(m *manifest.Manifest, dir Dir, meta Meta, ex Executor, opts Options) (*Engine, error) {
48+ if err := dir.Create(); err != nil {
49+ return nil, err
50+ }
51+ if _, err := os.Stat(dir.StateFile()); err == nil {
52+ return nil, fmt.Errorf("run %s already exists", dir)
53+ }
54+ if err := os.WriteFile(dir.ManifestFile(), m.Raw, 0o644); err != nil {
55+ return nil, err
56+ }
57+ log, err := runlog.Open(dir.Events(), meta.Run)
58+ if err != nil {
59+ return nil, err
60+ }
61+ t := now()
62+ e := &Engine{
63+ M: m, Dir: dir, Exec: ex, Log: log, BaseEnv: osEnviron(), Opts: opts,
64+ router: &Router{M: m},
65+ st: &State{Version: StateVersion, Meta: meta, Status: StatusRunning, Created: t},
66+ }
67+ for _, c := range meta.Checkouts {
68+ e.emit(lex.RunEvent{Kind: lex.KindCheckout, Repo: c.Repo, Rev: c.Rev, Path: c.Path})
69+ }
70+ detail := fmt.Sprintf("%s %s", m.Name, meta.Repo)
71+ if meta.Sha != "" {
72+ detail += "@" + short(meta.Sha)
73+ }
74+ if meta.Dirty {
75+ detail += " (working copy with changes)"
76+ }
77+ e.emit(lex.RunEvent{Kind: lex.KindRun, Status: StatusRunning, Content: detail})
78+ if err := e.st.Save(dir); err != nil {
79+ return nil, err
80+ }
81+ return e, nil
82+}
83+
84+func short(sha string) string {
85+ if len(sha) > 12 {
86+ return sha[:12]
87+ }
88+ return sha
89+}
90+
91+// State returns the run's current state.
92+func (e *Engine) State() *State { return e.st }
93+
94+// Run runs the workflow from its first step.
95+func (e *Engine) Run(ctx context.Context) (*State, error) {
96+ return e.drive(ctx, e.router.Start())
97+}
98+
99+// RunFrom runs the workflow starting at action, as if a person had chosen
100+// it; routing then follows that action's result.
101+func (e *Engine) RunFrom(ctx context.Context, action string) (*State, error) {
102+ if _, ok := e.M.Actions[action]; !ok {
103+ return nil, fmt.Errorf("no action %q in %s", action, e.M.Name)
104+ }
105+ return e.drive(ctx, e.router.Begin(action))
106+}
107+
108+func (e *Engine) maxTransitions() int {
109+ if e.Opts.MaxTransitions > 0 {
110+ return e.Opts.MaxTransitions
111+ }
112+ return DefaultMaxTransitions
113+}
114+
115+// drive follows decisions until the run ends.
116+func (e *Engine) drive(ctx context.Context, d Decision) (*State, error) {
117+ for {
118+ if ctx.Err() != nil {
119+ return e.finish(End{StatusCancelled, lex.ReasonCancel, "interrupted"}, "")
120+ }
121+ switch {
122+ case d.End != nil:
123+ return e.finish(*d.End, e.lastAction())
124+ case d.Gate != nil:
125+ return e.finish(End{StatusFailure, lex.ReasonError, "reached a gate (" + optionNames(d.Gate) + "); gates are not implemented yet"}, e.lastAction())
126+ case d.Run != nil:
127+ p := *d.Run
128+ if e.st.Seq >= e.maxTransitions() {
129+ return e.finish(End{StatusFailure, lex.ReasonLimit,
130+ fmt.Sprintf("more than %d transitions; is a result routing in a loop?", e.maxTransitions())}, p.From)
131+ }
132+ if p.Action == e.Opts.Until {
133+ return e.finish(End{StatusCancelled, lex.ReasonUntil, "stopped before " + p.Action + " (--until)"}, p.From)
134+ }
135+ e.transition(lex.Transition{From: p.From, To: p.Action, Then: p.Then, Reason: p.Reason})
136+ res, file, err := e.execute(ctx, p)
137+ if err != nil {
138+ if ctx.Err() != nil {
139+ return e.finish(End{StatusCancelled, lex.ReasonCancel, "interrupted during " + p.Action}, p.Action)
140+ }
141+ return e.finish(End{StatusFailure, lex.ReasonError, err.Error()}, p.Action)
142+ }
143+ next := e.router.After(e.st, p, res, file)
144+ if e.Opts.NoFollow {
145+ status := StatusSuccess
146+ if res.Status == lex.Failure {
147+ status = StatusFailure
148+ }
149+ return e.finish(End{status, lex.ReasonStop, "not followed (--no-follow): would " + next.String()}, p.Action)
150+ }
151+ d = next
152+ default:
153+ return e.finish(End{StatusFailure, lex.ReasonError, "router returned no decision"}, e.lastAction())
154+ }
155+ }
156+}
157+
158+func (e *Engine) lastAction() string {
159+ if x := e.st.LastExec(); x != nil {
160+ return x.Action
161+ }
162+ return ""
163+}
164+
165+func (e *Engine) transition(t lex.Transition) {
166+ e.st.Seq++
167+ t.Pipeline, t.Run, t.Workflow, t.Seq, t.CreatedAt = e.st.Meta.Pipeline, e.st.Meta.Run, e.st.Meta.Workflow, e.st.Seq, now()
168+ e.emit(lex.RunEvent{Kind: lex.KindTransition, Transition: &t})
169+}
170+
171+func (e *Engine) emit(ev lex.RunEvent) {
172+ if err := e.Log.Emit(ev); err != nil {
173+ fmt.Fprintf(os.Stderr, "ci: writing %s: %v\n", e.Dir.Events(), err)
174+ }
175+}
176+
177+// finish ends the run.
178+func (e *Engine) finish(end End, from string) (*State, error) {
179+ e.transition(lex.Transition{From: from, Reason: end.Reason, Detail: end.Detail})
180+ e.st.End = &end
181+ e.st.Status = end.Status
182+ e.st.Gate = nil
183+ e.emit(lex.RunEvent{Kind: lex.KindRun, Status: end.Status, Content: end.Detail})
184+ return e.st, e.st.Save(e.Dir)
185+}
186+
187+// execute runs one action and returns its (validated) result and the
188+// result file's path relative to the run dir. An error means the run can't
189+// go on (cancellation, an engine fault); a failing action is a result.
190+func (e *Engine) execute(ctx context.Context, p Pending) (lex.Result, string, error) {
191+ e.st.Execs++
192+ n := e.st.Execs
193+ if i, ok := e.M.IsStep(p.Action); ok {
194+ e.st.Cursor = i + 1
195+ }
196+ a := e.M.Actions[p.Action]
197+ root := e.st.Meta.Root
198+ dir := filepath.Clean(filepath.Join(root, a.Dir))
199+ base := fmt.Sprintf("%03d-%s", n, p.Action)
200+ resultRel := filepath.Join("results", base+".json")
201+ resultFile := filepath.Join(e.Dir.Path(), resultRel)
202+ inputsFile := filepath.Join(e.Dir.Results(), base+".inputs.json")
203+ inputs := p.Inputs
204+ if len(inputs) == 0 {
205+ inputs = json.RawMessage(`{}`)
206+ }
207+ if err := os.WriteFile(inputsFile, append(inputs, '\n'), 0o644); err != nil {
208+ return lex.Result{}, "", err
209+ }
210+ triggerFile := ""
211+ if p.TriggerResult != "" {
212+ triggerFile = filepath.Join(e.Dir.Path(), p.TriggerResult)
213+ }
214+ _ = os.Remove(resultFile)
215+
216+ x := Exec{N: n, Action: p.Action, Then: p.Then, Reason: p.Reason, Result: resultRel, Started: now()}
217+ e.st.History = append(e.st.History, x)
218+ if err := e.st.Save(e.Dir); err != nil {
219+ return lex.Result{}, "", err
220+ }
221+ e.emit(lex.RunEvent{Kind: lex.KindStart, Action: p.Action, Attempt: n, Command: a.Command})
222+
223+ code, runErr := 0, error(nil)
224+ if stub, ok := e.Opts.Stubs[p.Action]; ok {
225+ x.Stubbed = true
226+ code, runErr = e.stub(stub, resultFile, p.Action, n)
227+ } else {
228+ stdout := e.Log.Lines(p.Action, n, "stdout")
229+ stderr := e.Log.Lines(p.Action, n, "stderr")
230+ code, runErr = e.Exec.Exec(ctx, Spec{
231+ Root: root, Dir: dir, Command: a.Command,
232+ Env: e.actionEnv(p, n, resultFile, inputsFile, triggerFile),
233+ Stdout: stdout, Stderr: stderr,
234+ })
235+ stdout.Close()
236+ stderr.Close()
237+ }
238+ if runErr != nil && ctx.Err() != nil {
239+ if err := e.recordExec(x, StatusCancelled, -1); err != nil {
240+ return lex.Result{}, "", errors.Join(runErr, err)
241+ }
242+ return lex.Result{}, "", runErr
243+ }
244+
245+ var res lex.Result
246+ if runErr != nil {
247+ res = lex.Result{Version: lex.ResultVersion, Status: lex.Failure, Summary: "could not run: " + runErr.Error()}
248+ code = -1
249+ } else {
250+ var err error
251+ res, err = ReadResult(e.M, resultFile, code, e.Dir.Artifacts())
252+ if err != nil {
253+ res = ContractFailure(err)
254+ }
255+ }
256+ if err := mergeOutputs(e.Dir.Outputs(), res.Outputs); err != nil {
257+ res = ContractFailure(fmt.Errorf("outputs: %w", err))
258+ }
259+ exit := code
260+ e.emit(lex.RunEvent{Kind: lex.KindEnd, Action: p.Action, Attempt: n, ExitCode: &exit, Status: res.Status})
261+ e.emit(lex.RunEvent{Kind: lex.KindResult, Action: p.Action, Attempt: n, Result: &res})
262+ if err := e.recordExec(x, res.Status, code); err != nil {
263+ return lex.Result{}, "", err
264+ }
265+ return res, resultRel, nil
266+}
267+
268+func (e *Engine) recordExec(x Exec, status string, code int) error {
269+ x.Status, x.ExitCode, x.Finished = status, code, now()
270+ e.st.History[len(e.st.History)-1] = x
271+ return e.st.Save(e.Dir)
272+}
273+
274+// stub stands in for an action: the fixture becomes its result, and its
275+// exit code follows the fixture's status.
276+func (e *Engine) stub(fixture, resultFile, action string, n int) (int, error) {
277+ b, err := os.ReadFile(fixture)
278+ if err != nil {
279+ return -1, fmt.Errorf("stub for %s: %w", action, err)
280+ }
281+ if err := os.WriteFile(resultFile, b, 0o644); err != nil {
282+ return -1, err
283+ }
284+ e.emit(lex.RunEvent{Kind: lex.KindLog, Action: action, Attempt: n, Stream: "stderr",
285+ Content: "ci: stubbed with " + fixture})
286+ var probe struct {
287+ Status string `json:"status"`
288+ }
289+ if err := json.Unmarshal(b, &probe); err == nil && probe.Status == lex.Failure {
290+ return 1, nil
291+ }
292+ return 0, nil
293+}
294+
295+// ErrNoRun is returned when a run directory has no state.
296+var ErrNoRun = errors.New("no such run")
+346,
-0
1@@ -0,0 +1,346 @@
2+package engine
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "slices"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "git.kilimanjaro.io/ci/internal/lex"
15+ "git.kilimanjaro.io/ci/internal/manifest"
16+ "git.kilimanjaro.io/ci/internal/runlog"
17+)
18+
19+type fixture struct {
20+ t *testing.T
21+ root string
22+ dir Dir
23+ m *manifest.Manifest
24+ opts Options
25+}
26+
27+func newFixture(t *testing.T, wf string) *fixture {
28+ t.Helper()
29+ m, err := manifest.Parse("w", []byte(wf))
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ tmp := t.TempDir()
34+ root := filepath.Join(tmp, "repo")
35+ if err := os.MkdirAll(root, 0o755); err != nil {
36+ t.Fatal(err)
37+ }
38+ return &fixture{t: t, root: root, dir: Dir(filepath.Join(tmp, "run")), m: m}
39+}
40+
41+func (f *fixture) run(from string) (*State, []lex.RunEvent) {
42+ f.t.Helper()
43+ e, err := Create(f.m, f.dir, Meta{Run: "r1", Workflow: "w", Repo: "repo", Sha: "abc", Ref: "refs/heads/main", Kind: "manual", Host: "local", Root: f.root}, Bash{}, f.opts)
44+ if err != nil {
45+ f.t.Fatal(err)
46+ }
47+ var st *State
48+ if from == "" {
49+ st, err = e.Run(context.Background())
50+ } else {
51+ st, err = e.RunFrom(context.Background(), from)
52+ }
53+ e.Log.Close()
54+ if err != nil {
55+ f.t.Fatal(err)
56+ }
57+ b, err := os.Open(f.dir.Events())
58+ if err != nil {
59+ f.t.Fatal(err)
60+ }
61+ defer b.Close()
62+ evs, err := runlog.Read(b)
63+ if err != nil {
64+ f.t.Fatal(err)
65+ }
66+ return st, evs
67+}
68+
69+func executed(st *State) []string {
70+ var out []string
71+ for _, x := range st.History {
72+ out = append(out, x.Action)
73+ }
74+ return out
75+}
76+
77+// wf builds a manifest with block-scalar commands (YAML folds newlines in
78+// quoted strings). Each action is "name: command"; extra is appended to the
79+// action's mapping (e.g. "dir: sub").
80+func wf(steps string, actions ...string) string {
81+ var b strings.Builder
82+ b.WriteString("engine: devenv\nsteps: [" + steps + "]\nactions:\n")
83+ for _, a := range actions {
84+ name, rest, _ := strings.Cut(a, ": ")
85+ cmd, extra, _ := strings.Cut(rest, "\x00")
86+ b.WriteString(" " + name + ":\n")
87+ if extra != "" {
88+ b.WriteString(" " + extra + "\n")
89+ }
90+ b.WriteString(" command: |\n")
91+ for _, l := range strings.Split(cmd, "\n") {
92+ b.WriteString(" " + l + "\n")
93+ }
94+ }
95+ return b.String()
96+}
97+
98+// result writes a result JSON to $CI_RESULT from a shell action.
99+func result(json string) string {
100+ return "cat > \"$CI_RESULT\" <<'EOF'\n" + json + "\nEOF"
101+}
102+
103+func TestRunHappyPathAndEnv(t *testing.T) {
104+ f := newFixture(t, "environment: { GREETING: hi }\n"+wf("a, b",
105+ `a: echo "$GREETING $CI_ACTION $CI_ATTEMPT $CI_SHA $CI_HOST $TANGLED_REF_NAME $CI"
106+echo oops >&2
107+pwd`,
108+ "b: test -d \"$CI_STATE\" && test -d \"$CI_ARTIFACTS\" && test \"$(cat \"$CI_INPUTS\")\" = \"{}\" && test \"$(basename \"$PWD\")\" = sub\x00dir: sub",
109+ ))
110+ if err := os.MkdirAll(filepath.Join(f.root, "sub"), 0o755); err != nil {
111+ t.Fatal(err)
112+ }
113+ st, evs := f.run("")
114+ if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"a", "b"}) {
115+ t.Fatalf("status %s, ran %v, end %+v", st.Status, executed(st), st.End)
116+ }
117+ var out, errs []string
118+ for _, e := range evs {
119+ if e.Kind == lex.KindLog && e.Action == "a" {
120+ if e.Stream == "stdout" {
121+ out = append(out, e.Content)
122+ } else {
123+ errs = append(errs, e.Content)
124+ }
125+ }
126+ }
127+ // stdout and stderr interleave nondeterministically; each is ordered
128+ if !slices.Equal(out, []string{"hi a 1 abc local main true", f.root}) || !slices.Equal(errs, []string{"oops"}) {
129+ t.Errorf("stdout %q stderr %q", out, errs)
130+ }
131+ disk, err := Load(f.dir)
132+ if err != nil || disk.Status != StatusSuccess || disk.Cursor != 2 {
133+ t.Errorf("disk state %+v %v", disk, err)
134+ }
135+}
136+
137+func TestNoResultFileFallsBackToExitCode(t *testing.T) {
138+ f := newFixture(t, `
139+engine: devenv
140+steps: [a, b]
141+actions: { a: { command: "exit 3" }, b: { command: "true" } }
142+`)
143+ st, evs := f.run("")
144+ if st.Status != StatusFailure || !slices.Equal(executed(st), []string{"a"}) || st.History[0].ExitCode != 3 {
145+ t.Fatalf("%+v", st)
146+ }
147+ var end lex.RunEvent
148+ for _, e := range evs {
149+ if e.Kind == lex.KindEnd {
150+ end = e
151+ }
152+ }
153+ if end.ExitCode == nil || *end.ExitCode != 3 || end.Status != lex.Failure {
154+ t.Errorf("end event %+v", end)
155+ }
156+}
157+
158+func TestRoutingThroughResults(t *testing.T) {
159+ f := newFixture(t, wf("test, plan, record",
160+ "test: "+result(`{"version":1,"status":"success","next":{"action":"extra","then":"continue","inputs":{"n":7}},"outputs":{"x":1}}`),
161+ `extra: test "$(jq -r .n "$CI_INPUTS")" = 7 && test -s "$CI_TRIGGER_RESULT"`,
162+ "plan: "+result(`{"version":1,"status":"warning","summary":"careful","outputs":{"y":2}}`),
163+ `record: test "$(jq -c . "$CI_STATE/outputs.json")" = '{"x":1,"y":2}'`,
164+ ))
165+ st, _ := f.run("")
166+ if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"test", "extra", "plan", "record"}) {
167+ t.Fatalf("status %s ran %v end %+v", st.Status, executed(st), st.End)
168+ }
169+}
170+
171+func TestDivertAndStop(t *testing.T) {
172+ f := newFixture(t, wf("deploy, health, record",
173+ "deploy: true",
174+ "health: "+result(`{"version":1,"status":"failure","next":"undo"}`),
175+ "undo: true",
176+ "record: true",
177+ ))
178+ st, _ := f.run("")
179+ if st.Status != StatusFailure || !slices.Equal(executed(st), []string{"deploy", "health", "undo"}) {
180+ t.Fatalf("status %s ran %v", st.Status, executed(st))
181+ }
182+ if !strings.Contains(st.End.Detail, "before record") {
183+ t.Errorf("detail %q", st.End.Detail)
184+ }
185+}
186+
187+func TestContractErrors(t *testing.T) {
188+ cases := map[string]string{
189+ "unknown field": result(`{"version":1,"status":"success","summray":"x"}`),
190+ "undeclared next": result(`{"version":1,"status":"success","next":"nope"}`),
191+ "bad version": result(`{"version":2,"status":"success"}`),
192+ "exit 1 claims ok": result(`{"version":1,"status":"success"}`) + "\nexit 1",
193+ "missing attach": result(`{"version":1,"status":"success","attachments":[{"path":"none.txt"}]}`),
194+ "escaping attach": "touch \"$CI_ARTIFACTS/../x\"\n" + result(`{"version":1,"status":"success","attachments":[{"path":"../x"}]}`),
195+ "outputs not obj": result(`{"version":1,"status":"success","outputs":[1]}`),
196+ "not json": "echo nope > \"$CI_RESULT\"",
197+ "bad then": result(`{"version":1,"status":"success","next":{"action":"b","then":"later"}}`),
198+ }
199+ for name, cmd := range cases {
200+ t.Run(name, func(t *testing.T) {
201+ f := newFixture(t, wf("a, b", "a: "+cmd, "b: true"))
202+ st, evs := f.run("")
203+ if st.Status != StatusFailure || len(st.History) != 1 {
204+ t.Fatalf("status %s ran %v", st.Status, executed(st))
205+ }
206+ var r *lex.Result
207+ for _, e := range evs {
208+ if e.Kind == lex.KindResult {
209+ r = e.Result
210+ }
211+ }
212+ if r == nil || !strings.HasPrefix(r.Summary, "contract error") {
213+ t.Errorf("result %+v", r)
214+ }
215+ })
216+ }
217+}
218+
219+func TestAttachmentOK(t *testing.T) {
220+ f := newFixture(t, wf("a",
221+ "a: mkdir -p \"$CI_ARTIFACTS/d\" && echo x > \"$CI_ARTIFACTS/d/p.ndjson\"\n"+
222+ result(`{"version":1,"status":"success","attachments":[{"label":"probes","path":"d/p.ndjson","type":"ndjson"}]}`),
223+ ))
224+ st, _ := f.run("")
225+ if st.Status != StatusSuccess {
226+ t.Fatalf("%+v", st.End)
227+ }
228+}
229+
230+func TestLoopGuard(t *testing.T) {
231+ f := newFixture(t, wf("a", "a: "+result(`{"version":1,"status":"success","next":"a"}`)))
232+ f.opts.MaxTransitions = 5
233+ st, _ := f.run("")
234+ if st.Status != StatusFailure || st.End.Reason != lex.ReasonLimit || len(st.History) != 5 {
235+ t.Fatalf("status %s end %+v execs %d", st.Status, st.End, len(st.History))
236+ }
237+}
238+
239+func TestStubUntilNoFollow(t *testing.T) {
240+ wf := `
241+engine: devenv
242+steps: [test, deploy, health]
243+actions:
244+ test: { command: "true" }
245+ deploy: { command: "touch $CI_RUN_DIR/deployed" }
246+ health: { command: "exit 1" }
247+ undo: { command: "true" }
248+`
249+ // --stub replaces the action: health "fails" via a fixture that routes to undo
250+ f := newFixture(t, wf)
251+ stub := filepath.Join(t.TempDir(), "degraded.json")
252+ if err := os.WriteFile(stub, []byte(`{"version":1,"status":"failure","next":"undo"}`), 0o644); err != nil {
253+ t.Fatal(err)
254+ }
255+ f.opts.Stubs = map[string]string{"health": stub}
256+ st, _ := f.run("")
257+ if !slices.Equal(executed(st), []string{"test", "deploy", "health", "undo"}) || !st.History[2].Stubbed || st.History[2].ExitCode != 1 {
258+ t.Fatalf("ran %v %+v", executed(st), st.History)
259+ }
260+
261+ // --until stops before deploy
262+ f = newFixture(t, wf)
263+ f.opts.Until = "deploy"
264+ st, _ = f.run("")
265+ if st.Status != StatusCancelled || !slices.Equal(executed(st), []string{"test"}) {
266+ t.Fatalf("until: %s %v", st.Status, executed(st))
267+ }
268+ if _, err := os.Stat(filepath.Join(f.dir.Path(), "deployed")); err == nil {
269+ t.Error("deploy ran")
270+ }
271+
272+ // --no-follow runs one action and says what would happen
273+ f = newFixture(t, wf)
274+ f.opts.NoFollow = true
275+ st, _ = f.run("test")
276+ if !slices.Equal(executed(st), []string{"test"}) || !strings.Contains(st.End.Detail, "would run deploy") {
277+ t.Fatalf("no-follow: %v %+v", executed(st), st.End)
278+ }
279+}
280+
281+func TestRunFromOffPathAction(t *testing.T) {
282+ f := newFixture(t, `
283+engine: devenv
284+steps: [a, b]
285+actions: { a: { command: "true" }, b: { command: "true" }, c: { command: "true" } }
286+`)
287+ st, _ := f.run("c")
288+ // an off-path action started by hand stops after itself
289+ if !slices.Equal(executed(st), []string{"c"}) || st.Status != StatusFailure {
290+ t.Fatalf("%v %s %+v", executed(st), st.Status, st.End)
291+ }
292+ f = newFixture(t, `
293+engine: devenv
294+steps: [a, b]
295+actions: { a: { command: "true" }, b: { command: "true" } }
296+`)
297+ st, _ = f.run("b")
298+ if !slices.Equal(executed(st), []string{"b"}) || st.Status != StatusSuccess {
299+ t.Fatalf("%v %s", executed(st), st.Status)
300+ }
301+}
302+
303+func TestCancel(t *testing.T) {
304+ f := newFixture(t, `
305+engine: devenv
306+steps: [a, b]
307+actions: { a: { command: "sleep 30" }, b: { command: "true" } }
308+`)
309+ e, err := Create(f.m, f.dir, Meta{Run: "r1", Workflow: "w", Repo: "repo", Kind: "manual", Host: "local", Root: f.root}, Bash{}, f.opts)
310+ if err != nil {
311+ t.Fatal(err)
312+ }
313+ defer e.Log.Close()
314+ ctx, cancel := context.WithCancel(context.Background())
315+ go func() {
316+ for {
317+ if s, _ := Load(f.dir); s != nil && len(s.History) == 1 {
318+ cancel()
319+ return
320+ }
321+ time.Sleep(5 * time.Millisecond)
322+ }
323+ }()
324+ st, err := e.Run(ctx)
325+ if err != nil {
326+ t.Fatal(err)
327+ }
328+ if st.Status != StatusCancelled {
329+ t.Fatalf("status %s", st.Status)
330+ }
331+}
332+
333+func TestGateNotYetImplemented(t *testing.T) {
334+ f := newFixture(t, `
335+engine: devenv
336+steps: [a]
337+actions: { a: { command: "true", gate: true } }
338+`)
339+ st, _ := f.run("")
340+ if st.Status != StatusFailure || len(st.History) != 0 {
341+ t.Fatalf("%s %v", st.Status, executed(st))
342+ }
343+ b, _ := json.Marshal(st.End)
344+ if !strings.Contains(string(b), "not implemented") {
345+ t.Errorf("%s", b)
346+ }
347+}
+80,
-0
1@@ -0,0 +1,80 @@
2+package engine
3+
4+import (
5+ "os"
6+ "sort"
7+ "strconv"
8+ "strings"
9+)
10+
11+// actionEnv builds an action's environment (DESIGN.md §9): the engine's own
12+// environment without any CI_*/TANGLED_* left over from an enclosing run,
13+// then the manifest's environment, then the contract.
14+func (e *Engine) actionEnv(p Pending, n int, resultFile, inputsFile, triggerFile string) []string {
15+ env := map[string]string{}
16+ for _, kv := range e.BaseEnv {
17+ k, v, ok := strings.Cut(kv, "=")
18+ if !ok || strings.HasPrefix(k, "CI_") || strings.HasPrefix(k, "TANGLED_") {
19+ continue
20+ }
21+ env[k] = v
22+ }
23+ for k, v := range e.M.Environment {
24+ env[k] = v
25+ }
26+ m := e.st.Meta
27+ set := func(k, v string) { env[k] = v }
28+
29+ set("CI", "true")
30+ set("CI_RUN_ID", m.Run)
31+ set("CI_RUN_DIR", e.Dir.Path())
32+ set("CI_WORKFLOW", m.Workflow)
33+ set("CI_ACTION", p.Action)
34+ set("CI_ATTEMPT", strconv.Itoa(n))
35+ set("CI_RESULT", resultFile)
36+ set("CI_STATE", e.Dir.State())
37+ set("CI_ARTIFACTS", e.Dir.Artifacts())
38+ set("CI_INPUTS", inputsFile)
39+ set("CI_TRIGGER_RESULT", triggerFile)
40+ set("CI_SHA", m.Sha)
41+ set("CI_REF", m.Ref)
42+ set("CI_REPO", m.Repo)
43+ set("CI_ROOT", m.Root)
44+ set("CI_HOST", m.Host)
45+ for _, c := range m.Checkouts {
46+ if c.Env != "" {
47+ set(c.Env, c.Path)
48+ }
49+ }
50+
51+ // Upstream spindle compatibility (TANGLED_* as documented upstream).
52+ set("TANGLED_SHA", m.Sha)
53+ set("TANGLED_COMMIT_SHA", m.Sha)
54+ set("TANGLED_PIPELINE_KIND", m.Kind)
55+ set("TANGLED_PIPELINE_ID", m.Pipeline)
56+ set("TANGLED_REPO_NAME", m.Repo)
57+ set("TANGLED_REPO_DID", m.RepoDid)
58+ set("TANGLED_REPO_KNOT", m.Knot)
59+ set("TANGLED_REPO_DEFAULT_BRANCH", m.DefaultBranch)
60+ if m.Ref != "" {
61+ set("TANGLED_REF", m.Ref)
62+ switch {
63+ case strings.HasPrefix(m.Ref, "refs/heads/"):
64+ set("TANGLED_REF_NAME", strings.TrimPrefix(m.Ref, "refs/heads/"))
65+ set("TANGLED_REF_TYPE", "branch")
66+ case strings.HasPrefix(m.Ref, "refs/tags/"):
67+ set("TANGLED_REF_NAME", strings.TrimPrefix(m.Ref, "refs/tags/"))
68+ set("TANGLED_REF_TYPE", "tag")
69+ }
70+ }
71+
72+ out := make([]string, 0, len(env))
73+ for k, v := range env {
74+ out = append(out, k+"="+v)
75+ }
76+ sort.Strings(out)
77+ return out
78+}
79+
80+// osEnviron is os.Environ, replaceable in tests.
81+var osEnviron = os.Environ
+87,
-0
1@@ -0,0 +1,87 @@
2+package engine
3+
4+import (
5+ "context"
6+ "errors"
7+ "io"
8+ "os/exec"
9+ "syscall"
10+ "time"
11+)
12+
13+// Spec is one action execution.
14+type Spec struct {
15+ Root string // main checkout: where the devenv shell is activated
16+ Dir string // absolute working directory for the command
17+ Command string
18+ Env []string
19+ Stdout io.Writer
20+ Stderr io.Writer
21+}
22+
23+// Executor runs an action's command and returns its exit code. An error
24+// means the command could not be run at all (or was cancelled).
25+type Executor interface {
26+ Exec(ctx context.Context, s Spec) (int, error)
27+}
28+
29+// script runs the command in its working directory under bash's strict mode.
30+// The directory comes in through CI_ACTION_DIR so it needs no quoting.
31+func script(command string) string {
32+ return "cd -- \"$CI_ACTION_DIR\"\n" + command
33+}
34+
35+// Devenv runs actions in the main checkout's devenv shell: `devenv shell
36+// bash -euo pipefail -c <script>` with the main checkout as working
37+// directory, so the workflow's repo pins the toolchain (and the engine) even
38+// when the action's dir is another repo.
39+type Devenv struct {
40+ // Binary is the devenv executable; default "devenv".
41+ Binary string
42+}
43+
44+func (d Devenv) Exec(ctx context.Context, s Spec) (int, error) {
45+ bin := d.Binary
46+ if bin == "" {
47+ bin = "devenv"
48+ }
49+ return run(ctx, s, s.Root, bin, "shell", "bash", "-euo", "pipefail", "-c", script(s.Command))
50+}
51+
52+// Bash runs actions with plain bash, without devenv: for tests, and for
53+// debugging a recipe outside its shell (ci run --shell bash).
54+type Bash struct{}
55+
56+func (Bash) Exec(ctx context.Context, s Spec) (int, error) {
57+ return run(ctx, s, s.Dir, "bash", "-euo", "pipefail", "-c", script(s.Command))
58+}
59+
60+// run starts the command in its own process group, so cancelling kills the
61+// whole tree (devenv, bash and whatever the recipe started).
62+func run(ctx context.Context, s Spec, dir, name string, args ...string) (int, error) {
63+ cmd := exec.CommandContext(ctx, name, args...)
64+ cmd.Dir = dir
65+ cmd.Env = append(s.Env, "CI_ACTION_DIR="+s.Dir)
66+ cmd.Stdout = s.Stdout
67+ cmd.Stderr = s.Stderr
68+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
69+ cmd.Cancel = func() error {
70+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
71+ }
72+ cmd.WaitDelay = 10 * time.Second
73+ err := cmd.Run()
74+ if ctx.Err() != nil {
75+ return -1, ctx.Err()
76+ }
77+ var ee *exec.ExitError
78+ if errors.As(err, &ee) {
79+ if ee.ExitCode() >= 0 {
80+ return ee.ExitCode(), nil
81+ }
82+ return -1, err
83+ }
84+ if err != nil {
85+ return -1, err
86+ }
87+ return 0, nil
88+}
+146,
-0
1@@ -0,0 +1,146 @@
2+package engine
3+
4+import (
5+ "bytes"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "maps"
10+ "os"
11+ "path/filepath"
12+ "strings"
13+
14+ "git.kilimanjaro.io/ci/internal/lex"
15+ "git.kilimanjaro.io/ci/internal/manifest"
16+)
17+
18+// ReadResult reads the result an action left at path and checks it against
19+// the contract (DESIGN.md §7). A missing or empty file falls back to the exit
20+// code. Any contract violation is returned as an error; the caller turns it
21+// into a failure.
22+func ReadResult(m *manifest.Manifest, path string, exitCode int, artifacts string) (lex.Result, error) {
23+ b, err := os.ReadFile(path)
24+ if errors.Is(err, os.ErrNotExist) || (err == nil && len(bytes.TrimSpace(b)) == 0) {
25+ if exitCode == 0 {
26+ return lex.Result{Version: lex.ResultVersion, Status: lex.Success}, nil
27+ }
28+ return lex.Result{Version: lex.ResultVersion, Status: lex.Failure,
29+ Summary: fmt.Sprintf("exited with status %d", exitCode)}, nil
30+ }
31+ if err != nil {
32+ return lex.Result{}, err
33+ }
34+ var r lex.Result
35+ if err := lex.StrictUnmarshal(b, &r); err != nil {
36+ return lex.Result{}, fmt.Errorf("invalid result: %w", err)
37+ }
38+ return r, CheckResult(m, r, exitCode, artifacts)
39+}
40+
41+// CheckResult validates a decoded result.
42+func CheckResult(m *manifest.Manifest, r lex.Result, exitCode int, artifacts string) error {
43+ var errs []error
44+ bad := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
45+ if r.Version != lex.ResultVersion {
46+ bad("version %d (this engine speaks %d)", r.Version, lex.ResultVersion)
47+ }
48+ switch r.Status {
49+ case lex.Success, lex.Warning:
50+ if exitCode != 0 {
51+ bad("exited with status %d but reported %s", exitCode, r.Status)
52+ }
53+ case lex.Failure:
54+ default:
55+ bad("status %q (want success, warning or failure)", r.Status)
56+ }
57+ if r.Next != nil {
58+ for i, o := range r.Next.Options {
59+ if _, ok := m.Actions[o.Action]; !ok {
60+ bad("next[%d]: %q is not a declared action", i, o.Action)
61+ }
62+ if o.Then != "" && o.Then != lex.ThenContinue && o.Then != lex.ThenStop {
63+ bad("next[%d].then: %q (want continue or stop)", i, o.Then)
64+ }
65+ if len(o.Inputs) > 0 && !isObject(o.Inputs) {
66+ bad("next[%d].inputs: must be an object", i)
67+ }
68+ }
69+ }
70+ for i, a := range r.Attachments {
71+ if err := attachmentPath(artifacts, a.Path); err != nil {
72+ bad("attachments[%d]: %v", i, err)
73+ }
74+ switch a.Type {
75+ case "", "text", "markdown", "ndjson", "json":
76+ default:
77+ bad("attachments[%d].type: %q", i, a.Type)
78+ }
79+ }
80+ if len(r.Outputs) > 0 && !isObject(r.Outputs) {
81+ bad("outputs: must be an object")
82+ }
83+ return errors.Join(errs...)
84+}
85+
86+// attachmentPath checks that p names an existing file inside artifacts.
87+func attachmentPath(artifacts, p string) error {
88+ if p == "" || filepath.IsAbs(p) {
89+ return fmt.Errorf("path %q must be relative to $CI_ARTIFACTS", p)
90+ }
91+ c := filepath.Clean(p)
92+ if c == ".." || strings.HasPrefix(c, "../") {
93+ return fmt.Errorf("path %q leaves $CI_ARTIFACTS", p)
94+ }
95+ full := filepath.Join(artifacts, c)
96+ real, err := filepath.EvalSymlinks(full)
97+ if err != nil {
98+ return fmt.Errorf("path %q: %w", p, err)
99+ }
100+ root, err := filepath.EvalSymlinks(artifacts)
101+ if err != nil {
102+ return err
103+ }
104+ if rel, err := filepath.Rel(root, real); err != nil || rel == ".." || strings.HasPrefix(rel, "../") {
105+ return fmt.Errorf("path %q resolves outside $CI_ARTIFACTS", p)
106+ }
107+ if fi, err := os.Stat(real); err != nil || !fi.Mode().IsRegular() {
108+ return fmt.Errorf("path %q is not a regular file", p)
109+ }
110+ return nil
111+}
112+
113+func isObject(b json.RawMessage) bool {
114+ b = bytes.TrimSpace(b)
115+ return len(b) > 0 && b[0] == '{'
116+}
117+
118+// ContractFailure turns a contract violation into the failure result the
119+// router sees.
120+func ContractFailure(err error) lex.Result {
121+ return lex.Result{Version: lex.ResultVersion, Status: lex.Failure, Summary: "contract error: " + err.Error()}
122+}
123+
124+// mergeOutputs merges a result's outputs into the run's outputs.json.
125+func mergeOutputs(path string, outputs json.RawMessage) error {
126+ if len(outputs) == 0 {
127+ return nil
128+ }
129+ cur := map[string]json.RawMessage{}
130+ if b, err := os.ReadFile(path); err == nil {
131+ if err := json.Unmarshal(b, &cur); err != nil {
132+ return fmt.Errorf("%s: %w", path, err)
133+ }
134+ } else if !errors.Is(err, os.ErrNotExist) {
135+ return err
136+ }
137+ var add map[string]json.RawMessage
138+ if err := json.Unmarshal(outputs, &add); err != nil {
139+ return err
140+ }
141+ maps.Copy(cur, add)
142+ b, err := json.MarshalIndent(cur, "", " ")
143+ if err != nil {
144+ return err
145+ }
146+ return os.WriteFile(path, append(b, '\n'), 0o644)
147+}
+263,
-0
1@@ -0,0 +1,263 @@
2+package engine
3+
4+import (
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+
10+ "git.kilimanjaro.io/ci/internal/lex"
11+ "git.kilimanjaro.io/ci/internal/manifest"
12+)
13+
14+// Pending is an action about to run, and what happens after it succeeds.
15+type Pending struct {
16+ Action string `json:"action"`
17+ Then string `json:"then"`
18+ Reason string `json:"reason"`
19+ Inputs json.RawMessage `json:"inputs,omitempty"`
20+ // TriggerResult is the result file (relative to the run dir) of the
21+ // action whose result routed here, if any.
22+ TriggerResult string `json:"triggerResult,omitempty"`
23+ // From is the action that just finished, for the transition record.
24+ From string `json:"from,omitempty"`
25+}
26+
27+// PendingGate is a decision waiting for a human.
28+type PendingGate struct {
29+ Options []lex.Option `json:"options"`
30+ Prompt string `json:"prompt,omitempty"`
31+ Summary string `json:"summary,omitempty"`
32+ // From is the action whose result raised the gate. Empty for a declared
33+ // gate reached by default routing: dismissing that cancels the run.
34+ From string `json:"from,omitempty"`
35+ FromStatus string `json:"fromStatus,omitempty"`
36+ TriggerResult string `json:"triggerResult,omitempty"`
37+ CreatedAt string `json:"createdAt"`
38+ ExpiresAt string `json:"expiresAt"`
39+}
40+
41+// End is how a run finished.
42+type End struct {
43+ Status string `json:"status"`
44+ Reason string `json:"reason"`
45+ Detail string `json:"detail,omitempty"`
46+}
47+
48+// Decision is what the router says happens next: exactly one field is set.
49+type Decision struct {
50+ Run *Pending
51+ Gate *PendingGate
52+ End *End
53+}
54+
55+func (d Decision) String() string {
56+ switch {
57+ case d.Run != nil:
58+ return fmt.Sprintf("run %s (then %s, %s)", d.Run.Action, d.Run.Then, d.Run.Reason)
59+ case d.Gate != nil:
60+ var opts []string
61+ for _, o := range d.Gate.Options {
62+ opts = append(opts, o.Action)
63+ }
64+ return fmt.Sprintf("gate: %s", strings.Join(opts, " | "))
65+ case d.End != nil:
66+ s := fmt.Sprintf("end %s (%s)", d.End.Status, d.End.Reason)
67+ if d.End.Detail != "" {
68+ s += ": " + d.End.Detail
69+ }
70+ return s
71+ }
72+ return "nothing"
73+}
74+
75+// Router implements the routing rules of DESIGN.md §8 for one manifest.
76+type Router struct {
77+ M *manifest.Manifest
78+ Now func() time.Time
79+}
80+
81+func (r *Router) now() time.Time {
82+ if r.Now != nil {
83+ return r.Now()
84+ }
85+ return time.Now()
86+}
87+
88+// defaultThen is what happens after action succeeds when nobody said:
89+// happy-path steps continue, anything else stops.
90+func (r *Router) defaultThen(action string) string {
91+ if _, ok := r.M.IsStep(action); ok {
92+ return lex.ThenContinue
93+ }
94+ return lex.ThenStop
95+}
96+
97+// Start is the first decision of a run: the first step.
98+func (r *Router) Start() Decision {
99+ return r.reach(Pending{Action: r.M.Steps[0], Then: lex.ThenContinue, Reason: lex.ReasonStart})
100+}
101+
102+// Begin starts a run at an arbitrary action (ci action), as if a person had
103+// chosen it: a declared gate still applies.
104+func (r *Router) Begin(action string) Decision {
105+ return r.reach(Pending{Action: action, Then: r.defaultThen(action), Reason: lex.ReasonManual})
106+}
107+
108+// reach routes to p without a result asking for it, applying the action's
109+// declared gate.
110+func (r *Router) reach(p Pending) Decision {
111+ a := r.M.Actions[p.Action]
112+ if a.Gate == nil {
113+ return Decision{Run: &p}
114+ }
115+ prompt := a.Gate.Prompt
116+ if prompt == "" {
117+ prompt = fmt.Sprintf("Run %s?", r.M.Label(p.Action))
118+ }
119+ return Decision{Gate: r.gate(PendingGate{
120+ Options: []lex.Option{{Action: p.Action, Then: p.Then}},
121+ Prompt: prompt,
122+ })}
123+}
124+
125+// gate stamps g with its creation and expiry times. The timeout is the
126+// longest declared gate timeout among the options, or the default.
127+func (r *Router) gate(g PendingGate) *PendingGate {
128+ var timeout time.Duration
129+ for _, o := range g.Options {
130+ if r.M.Actions[o.Action].Gate != nil {
131+ timeout = max(timeout, r.M.GateTimeout(o.Action))
132+ }
133+ }
134+ if timeout == 0 {
135+ timeout = manifest.DefaultGateTimeout
136+ }
137+ t := r.now().UTC()
138+ g.CreatedAt = t.Format(time.RFC3339Nano)
139+ g.ExpiresAt = t.Add(timeout).Format(time.RFC3339Nano)
140+ return &g
141+}
142+
143+// After decides what follows finished, given its result. st.Cursor must
144+// already reflect finished having run.
145+func (r *Router) After(st *State, finished Pending, res lex.Result, resultFile string) Decision {
146+ if res.Next != nil {
147+ if res.Next.Stop {
148+ if res.Status == lex.Failure {
149+ return Decision{End: &End{StatusFailure, lex.ReasonStop, finished.Action + " failed and stopped the run"}}
150+ }
151+ return Decision{End: &End{StatusSuccess, lex.ReasonStop, finished.Action + " ended the run"}}
152+ }
153+ opts := make([]lex.Option, len(res.Next.Options))
154+ for i, o := range res.Next.Options {
155+ if o.Then == "" {
156+ o.Then = r.defaultThen(o.Action)
157+ }
158+ opts[i] = o
159+ }
160+ if r.needsGate(opts, res.Gated) {
161+ prompt := ""
162+ if len(opts) == 1 {
163+ prompt = opts[0].Prompt
164+ if prompt == "" {
165+ if g := r.M.Actions[opts[0].Action].Gate; g != nil && g.Prompt != "" {
166+ prompt = g.Prompt
167+ } else {
168+ prompt = fmt.Sprintf("Run %s?", r.M.Label(opts[0].Action))
169+ }
170+ }
171+ }
172+ return Decision{Gate: r.gate(PendingGate{
173+ Options: opts,
174+ Prompt: prompt,
175+ Summary: res.Summary,
176+ From: finished.Action,
177+ FromStatus: res.Status,
178+ TriggerResult: resultFile,
179+ })}
180+ }
181+ o := opts[0]
182+ return Decision{Run: &Pending{
183+ Action: o.Action, Then: o.Then, Reason: lex.ReasonNext, Inputs: o.Inputs,
184+ TriggerResult: resultFile, From: finished.Action,
185+ }}
186+ }
187+
188+ if res.Status == lex.Failure {
189+ return Decision{End: &End{StatusFailure, lex.ReasonFailure, finished.Action + " failed"}}
190+ }
191+ return r.continueOrStop(st, finished)
192+}
193+
194+// continueOrStop applies finished's `then` after it succeeded.
195+func (r *Router) continueOrStop(st *State, finished Pending) Decision {
196+ if finished.Then == lex.ThenStop {
197+ if st.Cursor >= len(r.M.Steps) {
198+ return Decision{End: &End{StatusSuccess, lex.ReasonStop, "stopped after " + finished.Action + " at the end of the happy path"}}
199+ }
200+ return Decision{End: &End{StatusFailure, lex.ReasonStop,
201+ fmt.Sprintf("stopped after %s, before %s", finished.Action, r.M.Steps[st.Cursor])}}
202+ }
203+ if st.Cursor >= len(r.M.Steps) {
204+ return Decision{End: &End{StatusSuccess, lex.ReasonDefault, "completed the happy path"}}
205+ }
206+ return r.reach(Pending{
207+ Action: r.M.Steps[st.Cursor], Then: lex.ThenContinue, Reason: lex.ReasonDefault, From: finished.Action,
208+ })
209+}
210+
211+// needsGate: a declared gate on any option, a result asking for one, or a
212+// choice between options. Gates are monotonic: a result can add one, never
213+// remove one.
214+func (r *Router) needsGate(opts []lex.Option, gated bool) bool {
215+ if gated || len(opts) > 1 {
216+ return true
217+ }
218+ for _, o := range opts {
219+ if r.M.Actions[o.Action].Gate != nil {
220+ return true
221+ }
222+ }
223+ return false
224+}
225+
226+// Choose answers a gate with option i. The chosen action runs without
227+// further gating: the person has just approved it.
228+func (r *Router) Choose(g *PendingGate, i int) (Decision, error) {
229+ if i < 0 || i >= len(g.Options) {
230+ return Decision{}, fmt.Errorf("no option %d", i)
231+ }
232+ o := g.Options[i]
233+ return Decision{Run: &Pending{
234+ Action: o.Action, Then: o.Then, Reason: lex.ReasonChoice, Inputs: o.Inputs,
235+ TriggerResult: g.TriggerResult, From: g.From,
236+ }}, nil
237+}
238+
239+// Dismiss ends the run at a gate: a declared gate reached by default
240+// routing cancels the run; a gate raised by a result ends the run with that
241+// result's status.
242+func (r *Router) Dismiss(g *PendingGate) Decision {
243+ if g.From == "" {
244+ return Decision{End: &End{StatusCancelled, lex.ReasonDismiss, "dismissed " + optionNames(g)}}
245+ }
246+ status := StatusSuccess
247+ if g.FromStatus == lex.Failure {
248+ status = StatusFailure
249+ }
250+ return Decision{End: &End{status, lex.ReasonDismiss, "dismissed " + optionNames(g) + " after " + g.From}}
251+}
252+
253+// Expire ends the run when a gate times out.
254+func (r *Router) Expire(g *PendingGate) Decision {
255+ return Decision{End: &End{StatusCancelled, lex.ReasonExpired, "gate expired: " + optionNames(g)}}
256+}
257+
258+func optionNames(g *PendingGate) string {
259+ var n []string
260+ for _, o := range g.Options {
261+ n = append(n, o.Action)
262+ }
263+ return strings.Join(n, ", ")
264+}
+237,
-0
1@@ -0,0 +1,237 @@
2+package engine
3+
4+import (
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+
9+ "git.kilimanjaro.io/ci/internal/lex"
10+ "git.kilimanjaro.io/ci/internal/manifest"
11+)
12+
13+const routerWF = `
14+engine: devenv
15+steps: [test, plan, deploy, health-check, record]
16+actions:
17+ test: { command: x }
18+ plan: { command: x }
19+ deploy: { command: x, gate: { prompt: "Deploy?" } }
20+ health-check: { command: x }
21+ record: { command: x }
22+ artifacts: { command: x }
23+ rollback: { command: x, gate: { prompt: "Roll back?", timeout: 1h } }
24+ notify: { command: x }
25+`
26+
27+func newRouter(t *testing.T) *Router {
28+ t.Helper()
29+ m, err := manifest.Parse("w", []byte(routerWF))
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ return &Router{M: m}
34+}
35+
36+// ran simulates the engine executing p: it moves the cursor.
37+func ran(r *Router, st *State, p Pending) {
38+ if i, ok := r.M.IsStep(p.Action); ok {
39+ st.Cursor = i + 1
40+ }
41+}
42+
43+func res(status string, next string) lex.Result {
44+ r := lex.Result{Version: 1, Status: status}
45+ if next != "" {
46+ var n lex.Next
47+ if err := json.Unmarshal([]byte(next), &n); err != nil {
48+ panic(err)
49+ }
50+ r.Next = &n
51+ }
52+ return r
53+}
54+
55+func mustRun(t *testing.T, d Decision, action, then string) Pending {
56+ t.Helper()
57+ if d.Run == nil || d.Run.Action != action || d.Run.Then != then {
58+ t.Fatalf("want run %s (then %s), got %s", action, then, d)
59+ }
60+ return *d.Run
61+}
62+
63+func mustGate(t *testing.T, d Decision, actions ...string) *PendingGate {
64+ t.Helper()
65+ if d.Gate == nil {
66+ t.Fatalf("want gate %v, got %s", actions, d)
67+ }
68+ var got []string
69+ for _, o := range d.Gate.Options {
70+ got = append(got, o.Action)
71+ }
72+ if strings.Join(got, ",") != strings.Join(actions, ",") {
73+ t.Fatalf("gate options %v, want %v", got, actions)
74+ }
75+ return d.Gate
76+}
77+
78+func mustEnd(t *testing.T, d Decision, status string) *End {
79+ t.Helper()
80+ if d.End == nil || d.End.Status != status {
81+ t.Fatalf("want end %s, got %s", status, d)
82+ }
83+ return d.End
84+}
85+
86+func TestHappyPathWithDeclaredGate(t *testing.T) {
87+ r := newRouter(t)
88+ st := &State{}
89+ p := mustRun(t, r.Start(), "test", "continue")
90+ ran(r, st, p)
91+ p = mustRun(t, r.After(st, p, res("success", ""), "r1"), "plan", "continue")
92+ ran(r, st, p)
93+ g := mustGate(t, r.After(st, p, res("warning", ""), "r2"), "deploy")
94+ if g.From != "" || g.Prompt != "Deploy?" {
95+ t.Errorf("declared gate: %+v", g)
96+ }
97+ d, err := r.Choose(g, 0)
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ p = mustRun(t, d, "deploy", "continue")
102+ if p.Reason != lex.ReasonChoice {
103+ t.Errorf("reason %s", p.Reason)
104+ }
105+ ran(r, st, p)
106+ p = mustRun(t, r.After(st, p, res("success", ""), "r3"), "health-check", "continue")
107+ ran(r, st, p)
108+ p = mustRun(t, r.After(st, p, res("success", ""), "r4"), "record", "continue")
109+ ran(r, st, p)
110+ e := mustEnd(t, r.After(st, p, res("success", ""), "r5"), StatusSuccess)
111+ if e.Reason != lex.ReasonDefault {
112+ t.Errorf("reason %s", e.Reason)
113+ }
114+}
115+
116+func TestFailureStops(t *testing.T) {
117+ r := newRouter(t)
118+ st := &State{}
119+ p := mustRun(t, r.Start(), "test", "continue")
120+ ran(r, st, p)
121+ mustEnd(t, r.After(st, p, res("failure", ""), "r1"), StatusFailure)
122+}
123+
124+func TestInsertedStepResumesHappyPath(t *testing.T) {
125+ r := newRouter(t)
126+ st := &State{}
127+ p := mustRun(t, r.Start(), "test", "continue")
128+ ran(r, st, p)
129+ p = mustRun(t, r.After(st, p, res("success", `{"action":"artifacts","then":"continue","inputs":{"k":1}}`), "results/001-test.json"), "artifacts", "continue")
130+ if p.TriggerResult != "results/001-test.json" || string(p.Inputs) != `{"k":1}` || p.From != "test" {
131+ t.Errorf("pending %+v", p)
132+ }
133+ ran(r, st, p)
134+ mustRun(t, r.After(st, p, res("success", ""), "r2"), "plan", "continue")
135+}
136+
137+func TestOffPathDefaultsToStop(t *testing.T) {
138+ r := newRouter(t)
139+ st := &State{}
140+ p := mustRun(t, r.Start(), "test", "continue")
141+ ran(r, st, p)
142+ p = mustRun(t, r.After(st, p, res("success", `"notify"`), "r1"), "notify", "stop")
143+ ran(r, st, p)
144+ e := mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusFailure)
145+ if !strings.Contains(e.Detail, "before plan") {
146+ t.Errorf("detail %q", e.Detail)
147+ }
148+}
149+
150+func TestJumpForwardSkipsAndBackRepeats(t *testing.T) {
151+ r := newRouter(t)
152+ st := &State{}
153+ p := mustRun(t, r.Start(), "test", "continue")
154+ ran(r, st, p)
155+ // jump forward to record: a happy-path step, so then defaults to continue
156+ p = mustRun(t, r.After(st, p, res("success", `"record"`), "r1"), "record", "continue")
157+ ran(r, st, p)
158+ mustEnd(t, r.After(st, p, res("success", ""), "r2"), StatusSuccess)
159+
160+ // jump back from health-check to plan repeats plan and what follows
161+ st = &State{Cursor: 4}
162+ p = Pending{Action: "health-check", Then: "continue"}
163+ p = mustRun(t, r.After(st, p, res("success", `"plan"`), "r3"), "plan", "continue")
164+ ran(r, st, p)
165+ mustGate(t, r.After(st, p, res("success", ""), "r4"), "deploy")
166+}
167+
168+func TestResultGates(t *testing.T) {
169+ r := newRouter(t)
170+ st := &State{Cursor: 4}
171+ hc := Pending{Action: "health-check", Then: "continue"}
172+
173+ // a declared gate on the routed-to action gates even without gated:true
174+ g := mustGate(t, r.After(st, hc, res("failure", `{"action":"rollback","then":"stop"}`), "r"), "rollback")
175+ if g.From != "health-check" || g.FromStatus != "failure" || g.Prompt != "Roll back?" {
176+ t.Errorf("gate %+v", g)
177+ }
178+
179+ // gated:true on an ungated action
180+ rr := res("warning", `"notify"`)
181+ rr.Gated = true
182+ mustGate(t, r.After(st, hc, rr, "r"), "notify")
183+
184+ // more than one option is always a choice
185+ g = mustGate(t, r.After(st, hc, res("failure", `[{"action":"rollback"},{"action":"health-check","label":"Check again"}]`), "r"), "rollback", "health-check")
186+ if g.Options[0].Then != "stop" || g.Options[1].Then != "continue" {
187+ t.Errorf("default thens: %+v", g.Options)
188+ }
189+
190+ // choosing runs the option without gating again
191+ d, _ := r.Choose(g, 1)
192+ mustRun(t, d, "health-check", "continue")
193+ if _, err := r.Choose(g, 5); err == nil {
194+ t.Error("choose out of range")
195+ }
196+}
197+
198+func TestGateTimeout(t *testing.T) {
199+ r := newRouter(t)
200+ g := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("failure", `"rollback"`), "r"), "rollback")
201+ if g.ExpiresAt == "" || g.CreatedAt == "" || g.ExpiresAt <= g.CreatedAt {
202+ t.Errorf("times %+v", g)
203+ }
204+}
205+
206+func TestDismiss(t *testing.T) {
207+ r := newRouter(t)
208+ declared := mustGate(t, r.reach(Pending{Action: "deploy", Then: "continue"}), "deploy")
209+ mustEnd(t, r.Dismiss(declared), StatusCancelled)
210+
211+ raised := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("failure", `"rollback"`), "r"), "rollback")
212+ mustEnd(t, r.Dismiss(raised), StatusFailure)
213+
214+ warn := mustGate(t, r.After(&State{}, Pending{Action: "health-check"}, res("warning", `[{"action":"rollback"},{"action":"notify"}]`), "r"), "rollback", "notify")
215+ mustEnd(t, r.Dismiss(warn), StatusSuccess)
216+
217+ mustEnd(t, r.Expire(declared), StatusCancelled)
218+}
219+
220+func TestExplicitStop(t *testing.T) {
221+ r := newRouter(t)
222+ mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("success", `"stop"`), "r"), StatusSuccess)
223+ mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("warning", `"stop"`), "r"), StatusSuccess)
224+ mustEnd(t, r.After(&State{}, Pending{Action: "test"}, res("failure", `"stop"`), "r"), StatusFailure)
225+}
226+
227+func TestFailureWithNextRoutes(t *testing.T) {
228+ // a failing result that names a next action is routed, not stopped
229+ r := newRouter(t)
230+ mustRun(t, r.After(&State{}, Pending{Action: "test"}, res("failure", `"notify"`), "r"), "notify", "stop")
231+}
232+
233+func TestBeginAppliesDeclaredGate(t *testing.T) {
234+ r := newRouter(t)
235+ mustGate(t, r.Begin("rollback"), "rollback")
236+ mustRun(t, r.Begin("notify"), "notify", "stop")
237+ mustRun(t, r.Begin("plan"), "plan", "continue")
238+}
+152,
-0
1@@ -0,0 +1,152 @@
2+// Package engine runs a workflow: it executes actions, reads their results,
3+// routes between actions (DESIGN.md §8), and records everything as
4+// events.ndjson and state.json in the run directory. The same engine runs on
5+// the dev machine and, inside a sandboxed unit, on the runner.
6+package engine
7+
8+import (
9+ "encoding/json"
10+ "fmt"
11+ "os"
12+ "path/filepath"
13+ "time"
14+)
15+
16+// Run statuses (state.json and kind=run events).
17+const (
18+ StatusRunning = "running"
19+ StatusPaused = "paused"
20+ StatusSuccess = "success"
21+ StatusFailure = "failure"
22+ StatusCancelled = "cancelled"
23+)
24+
25+// Terminal reports whether status ends a run.
26+func Terminal(status string) bool {
27+ return status == StatusSuccess || status == StatusFailure || status == StatusCancelled
28+}
29+
30+// StateVersion versions state.json.
31+const StateVersion = 1
32+
33+// Dir is a run directory (DESIGN.md §9).
34+type Dir string
35+
36+func (d Dir) Path() string { return string(d) }
37+func (d Dir) StateFile() string { return filepath.Join(string(d), "state.json") }
38+func (d Dir) Events() string { return filepath.Join(string(d), "events.ndjson") }
39+func (d Dir) ManifestFile() string { return filepath.Join(string(d), "manifest.yml") }
40+func (d Dir) State() string { return filepath.Join(string(d), "state") }
41+func (d Dir) Artifacts() string { return filepath.Join(string(d), "artifacts") }
42+func (d Dir) Results() string { return filepath.Join(string(d), "results") }
43+func (d Dir) Outputs() string { return filepath.Join(d.State(), "outputs.json") }
44+
45+// Create makes the run directory and its subdirectories.
46+func (d Dir) Create() error {
47+ for _, p := range []string{d.Path(), d.State(), d.Artifacts(), d.Results()} {
48+ if err := os.MkdirAll(p, 0o755); err != nil {
49+ return err
50+ }
51+ }
52+ return nil
53+}
54+
55+// Meta describes what a run is running. It is fixed when the run is created.
56+type Meta struct {
57+ Run string `json:"run"`
58+ Workflow string `json:"workflow"`
59+ Repo string `json:"repo"` // repository name
60+ RepoDid string `json:"repoDid,omitempty"` // did:web:<forge>:<repo> on the runner
61+ Knot string `json:"knot,omitempty"`
62+ Sha string `json:"sha,omitempty"`
63+ Ref string `json:"ref,omitempty"`
64+ DefaultBranch string `json:"defaultBranch,omitempty"`
65+ Kind string `json:"kind"` // push | manual
66+ Pipeline string `json:"pipeline,omitempty"` // AT-URI on the runner
67+ Host string `json:"host"` // local | runner
68+ Root string `json:"root"` // main checkout
69+ Dirty bool `json:"dirty,omitempty"` // local working copy with changes
70+ // Checkouts are the extra repos (manifest repos:), by name.
71+ Checkouts []Checkout `json:"checkouts,omitempty"`
72+}
73+
74+// Checkout is an extra repository checked out for a run.
75+type Checkout struct {
76+ Repo string `json:"repo"`
77+ Path string `json:"path"` // absolute
78+ Rev string `json:"rev"`
79+ Env string `json:"env,omitempty"`
80+}
81+
82+// Exec records one execution of an action.
83+type Exec struct {
84+ N int `json:"n"`
85+ Action string `json:"action"`
86+ Then string `json:"then"`
87+ Reason string `json:"reason"`
88+ Status string `json:"status"`
89+ ExitCode int `json:"exitCode"`
90+ Result string `json:"result"` // path relative to the run dir
91+ Stubbed bool `json:"stubbed,omitempty"`
92+ Started string `json:"started"`
93+ Finished string `json:"finished,omitempty"`
94+}
95+
96+// State is state.json: everything needed to resume or inspect a run.
97+type State struct {
98+ Version int `json:"version"`
99+ Meta Meta `json:"meta"`
100+ Status string `json:"status"`
101+ // Cursor is the index in steps of the next happy-path step.
102+ Cursor int `json:"cursor"`
103+ // Seq counts transitions; Execs counts executions.
104+ Seq int `json:"seq"`
105+ Execs int `json:"execs"`
106+ History []Exec `json:"history,omitempty"`
107+ // Gate is set while the run is paused at a gate.
108+ Gate *PendingGate `json:"gate,omitempty"`
109+ // End is set when the run has finished.
110+ End *End `json:"end,omitempty"`
111+ Created string `json:"created"`
112+ Updated string `json:"updated"`
113+}
114+
115+// LastExec returns the most recent execution, if any.
116+func (s *State) LastExec() *Exec {
117+ if len(s.History) == 0 {
118+ return nil
119+ }
120+ return &s.History[len(s.History)-1]
121+}
122+
123+// Load reads a run's state.json.
124+func Load(d Dir) (*State, error) {
125+ b, err := os.ReadFile(d.StateFile())
126+ if err != nil {
127+ return nil, err
128+ }
129+ var s State
130+ if err := json.Unmarshal(b, &s); err != nil {
131+ return nil, fmt.Errorf("%s: %w", d.StateFile(), err)
132+ }
133+ if s.Version != StateVersion {
134+ return nil, fmt.Errorf("%s: state version %d, this engine reads %d", d.StateFile(), s.Version, StateVersion)
135+ }
136+ return &s, nil
137+}
138+
139+// Save writes state.json atomically.
140+func (s *State) Save(d Dir) error {
141+ s.Updated = now()
142+ b, err := json.MarshalIndent(s, "", " ")
143+ if err != nil {
144+ return err
145+ }
146+ tmp := d.StateFile() + ".tmp"
147+ if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil {
148+ return err
149+ }
150+ return os.Rename(tmp, d.StateFile())
151+}
152+
153+func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }
+4,
-0
1@@ -35,6 +35,7 @@ type Trigger struct {
2 Event []string `json:"event"`
3 Branch []string `json:"branch,omitempty"`
4 Tag []string `json:"tag,omitempty"`
5+ Paths []string `json:"paths,omitempty"`
6 }
7
8 type Repo struct {
9@@ -209,6 +210,9 @@ const (
10 ReasonFailure = "failure"
11 ReasonError = "error"
12 ReasonLimit = "limit"
13+ ReasonExpired = "expired"
14+ ReasonUntil = "until"
15+ ReasonCancel = "cancelled"
16 )
17
18 // Transition is run.rtw.ci.transition.
+299,
-0
1@@ -0,0 +1,299 @@
2+// Package manifest loads and validates workflow manifests
3+// (.tangled/workflows/<name>.yml with engine devenv) and matches their
4+// triggers.
5+//
6+// YAML is decoded generically, its shorthands are normalised (a string for a
7+// list, `gate: true`, `concurrency: <group>`), and the result is decoded into
8+// lex.Workflow through strict JSON, so the YAML and JSON forms share one
9+// validation path and unknown fields are errors.
10+package manifest
11+
12+import (
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "os"
17+ "path"
18+ "path/filepath"
19+ "regexp"
20+ "slices"
21+ "sort"
22+ "strings"
23+ "time"
24+
25+ "gopkg.in/yaml.v3"
26+
27+ "git.kilimanjaro.io/ci/internal/lex"
28+)
29+
30+// Dir is where workflow files live, relative to the repository root.
31+const Dir = ".tangled/workflows"
32+
33+// DefaultGateTimeout applies when a gate declares none.
34+const DefaultGateTimeout = 24 * time.Hour
35+
36+var (
37+ actionName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
38+ envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
39+)
40+
41+// ErrOtherEngine is returned by Parse for a workflow that doesn't use the
42+// devenv engine: it belongs to another runner and is skipped, not invalid.
43+var ErrOtherEngine = errors.New("not a devenv workflow")
44+
45+// Manifest is a parsed, validated workflow.
46+type Manifest struct {
47+ Name string // file name without extension
48+ Raw []byte // the file as written
49+ lex.Workflow
50+}
51+
52+// Load reads and parses the workflow file at p.
53+func Load(p string) (*Manifest, error) {
54+ b, err := os.ReadFile(p)
55+ if err != nil {
56+ return nil, err
57+ }
58+ m, err := Parse(NameOf(p), b)
59+ if err != nil {
60+ return nil, fmt.Errorf("%s: %w", p, err)
61+ }
62+ return m, nil
63+}
64+
65+// NameOf returns a workflow's name: its file name without .yml/.yaml.
66+func NameOf(p string) string {
67+ base := path.Base(filepath.ToSlash(p))
68+ return strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml")
69+}
70+
71+// Find returns the path of the named workflow under root.
72+func Find(root, name string) (string, error) {
73+ for _, ext := range []string{".yml", ".yaml"} {
74+ p := filepath.Join(root, Dir, name+ext)
75+ if _, err := os.Stat(p); err == nil {
76+ return p, nil
77+ }
78+ }
79+ return "", fmt.Errorf("no workflow %q in %s", name, filepath.Join(root, Dir))
80+}
81+
82+// List returns the workflow files under root, sorted.
83+func List(root string) ([]string, error) {
84+ var out []string
85+ for _, pat := range []string{"*.yml", "*.yaml"} {
86+ m, err := filepath.Glob(filepath.Join(root, Dir, pat))
87+ if err != nil {
88+ return nil, err
89+ }
90+ out = append(out, m...)
91+ }
92+ sort.Strings(out)
93+ return out, nil
94+}
95+
96+// Parse decodes and validates a manifest.
97+func Parse(name string, b []byte) (*Manifest, error) {
98+ var doc any
99+ if err := yaml.Unmarshal(b, &doc); err != nil {
100+ return nil, err
101+ }
102+ top, ok := doc.(map[string]any)
103+ if !ok {
104+ return nil, fmt.Errorf("a workflow must be a mapping")
105+ }
106+ if e, _ := top["engine"].(string); e != lex.EngineDevenv {
107+ return nil, fmt.Errorf("%w (engine %q)", ErrOtherEngine, e)
108+ }
109+ if err := normalise(top); err != nil {
110+ return nil, err
111+ }
112+ j, err := json.Marshal(top)
113+ if err != nil {
114+ return nil, err
115+ }
116+ m := &Manifest{Name: name, Raw: b}
117+ if err := lex.StrictUnmarshal(j, &m.Workflow); err != nil {
118+ return nil, err
119+ }
120+ if err := m.validate(); err != nil {
121+ return nil, err
122+ }
123+ return m, nil
124+}
125+
126+// normalise rewrites YAML shorthands into the lexicon's JSON shape, in place.
127+func normalise(top map[string]any) error {
128+ if when, ok := top["when"].([]any); ok {
129+ for i, c := range when {
130+ cm, ok := c.(map[string]any)
131+ if !ok {
132+ return fmt.Errorf("when[%d]: must be a mapping", i)
133+ }
134+ for _, k := range []string{"event", "branch", "tag", "paths"} {
135+ if s, ok := cm[k].(string); ok {
136+ cm[k] = []any{s}
137+ }
138+ }
139+ }
140+ }
141+ if acts, ok := top["actions"].(map[string]any); ok {
142+ for name, a := range acts {
143+ am, ok := a.(map[string]any)
144+ if !ok {
145+ return fmt.Errorf("actions.%s: must be a mapping", name)
146+ }
147+ switch g := am["gate"].(type) {
148+ case bool:
149+ if g {
150+ am["gate"] = map[string]any{}
151+ } else {
152+ delete(am, "gate")
153+ }
154+ case nil:
155+ if _, present := am["gate"]; present {
156+ am["gate"] = map[string]any{}
157+ }
158+ }
159+ if s, ok := am["concurrency"].(string); ok {
160+ am["concurrency"] = map[string]any{"group": s}
161+ }
162+ }
163+ }
164+ if env, ok := top["environment"].(map[string]any); ok {
165+ for k, v := range env {
166+ switch v.(type) {
167+ case string:
168+ case int, int64, float64, bool:
169+ env[k] = fmt.Sprint(v)
170+ default:
171+ return fmt.Errorf("environment.%s: must be a string", k)
172+ }
173+ }
174+ }
175+ return nil
176+}
177+
178+func (m *Manifest) validate() error {
179+ var errs []error
180+ bad := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
181+
182+ if len(m.Steps) == 0 {
183+ bad("steps: at least one step is required")
184+ }
185+ seen := map[string]bool{}
186+ for i, s := range m.Steps {
187+ if _, ok := m.Actions[s]; !ok {
188+ bad("steps[%d]: %q is not a declared action", i, s)
189+ }
190+ if seen[s] {
191+ bad("steps[%d]: %q appears twice (a result can route back to a step instead)", i, s)
192+ }
193+ seen[s] = true
194+ }
195+ names := make([]string, 0, len(m.Actions))
196+ for n := range m.Actions {
197+ names = append(names, n)
198+ }
199+ sort.Strings(names)
200+ for _, n := range names {
201+ a := m.Actions[n]
202+ if !actionName.MatchString(n) || n == lex.ThenStop {
203+ bad("actions.%s: invalid name (letters, digits, . _ -; not %q)", n, lex.ThenStop)
204+ }
205+ if strings.TrimSpace(a.Command) == "" {
206+ bad("actions.%s: command is required", n)
207+ }
208+ if a.Dir != "" {
209+ if err := relPath(a.Dir); err != nil {
210+ bad("actions.%s.dir: %v", n, err)
211+ }
212+ }
213+ if a.Style != "" && a.Style != "normal" && a.Style != "danger" {
214+ bad("actions.%s.style: %q (want normal or danger)", n, a.Style)
215+ }
216+ if a.Gate != nil && a.Gate.Timeout != "" {
217+ if d, err := time.ParseDuration(a.Gate.Timeout); err != nil || d <= 0 {
218+ bad("actions.%s.gate.timeout: %q is not a positive duration", n, a.Gate.Timeout)
219+ }
220+ }
221+ if a.Concurrency != nil && a.Concurrency.Group == "" {
222+ bad("actions.%s.concurrency: group is required", n)
223+ }
224+ }
225+ paths := map[string]bool{}
226+ for i, r := range m.Repos {
227+ if r.Repo == "" {
228+ bad("repos[%d]: repo is required", i)
229+ }
230+ if err := relPath(r.Path); err != nil {
231+ bad("repos[%d].path: %v", i, err)
232+ } else if c := filepath.Clean(r.Path); c == "." {
233+ bad("repos[%d].path: must not be the main checkout", i)
234+ } else if paths[c] {
235+ bad("repos[%d].path: %q used twice", i, r.Path)
236+ } else {
237+ paths[c] = true
238+ }
239+ if r.Env != "" && !envName.MatchString(r.Env) {
240+ bad("repos[%d].env: %q is not a valid variable name", i, r.Env)
241+ }
242+ }
243+ for k := range m.Environment {
244+ if !envName.MatchString(k) {
245+ bad("environment: %q is not a valid variable name", k)
246+ }
247+ }
248+ for i, c := range m.When {
249+ if len(c.Event) == 0 {
250+ bad("when[%d]: event is required", i)
251+ }
252+ for _, e := range c.Event {
253+ if !slices.Contains([]string{"push", "manual", "pull_request", "schedule"}, e) {
254+ bad("when[%d]: unknown event %q", i, e)
255+ }
256+ }
257+ for _, p := range slices.Concat(c.Branch, c.Tag, c.Paths) {
258+ if !validGlob(p) {
259+ bad("when[%d]: invalid glob %q", i, p)
260+ }
261+ }
262+ }
263+ return errors.Join(errs...)
264+}
265+
266+// relPath checks that p is a relative path. It may climb out of the main
267+// checkout (../servers): extra repos sit next to it.
268+func relPath(p string) error {
269+ if p == "" {
270+ return fmt.Errorf("must not be empty")
271+ }
272+ if filepath.IsAbs(p) {
273+ return fmt.Errorf("%q must be relative to the main checkout", p)
274+ }
275+ return nil
276+}
277+
278+// IsStep reports whether action is on the happy path, and its index.
279+func (m *Manifest) IsStep(action string) (int, bool) {
280+ i := slices.Index(m.Steps, action)
281+ return i, i >= 0
282+}
283+
284+// Label returns an action's display label.
285+func (m *Manifest) Label(action string) string {
286+ if a, ok := m.Actions[action]; ok && a.Label != "" {
287+ return a.Label
288+ }
289+ return action
290+}
291+
292+// GateTimeout returns the timeout of an action's declared gate.
293+func (m *Manifest) GateTimeout(action string) time.Duration {
294+ a := m.Actions[action]
295+ if a.Gate == nil || a.Gate.Timeout == "" {
296+ return DefaultGateTimeout
297+ }
298+ d, _ := time.ParseDuration(a.Gate.Timeout)
299+ return d
300+}
+201,
-0
1@@ -0,0 +1,201 @@
2+package manifest
3+
4+import (
5+ "errors"
6+ "strings"
7+ "testing"
8+)
9+
10+const sovrn = `
11+when:
12+ - event: [push]
13+ branch: [main]
14+engine: devenv
15+repos:
16+ - { repo: servers, path: ../servers }
17+ - { repo: data, path: ../data, env: SECRETS_DIR }
18+steps: [test, plan, deploy, health-check, record, notify]
19+actions:
20+ test: { command: just test }
21+ plan: { dir: ../servers, command: just plan-project sovrn }
22+ deploy: { dir: ../servers, command: just deploy-project sovrn,
23+ gate: { prompt: "Deploy sovrn?" }, concurrency: fleet,
24+ audited: true }
25+ health-check: { dir: ../servers, command: just prod-health-check sovrn }
26+ record: { dir: ../servers, command: just record-deploy sovrn }
27+ notify: { command: just notify-deploy }
28+ rollback: { dir: ../servers, command: just rollback, style: danger,
29+ manual: true, audited: true,
30+ gate: { prompt: "Roll back sovrn?" } }
31+`
32+
33+func TestParseDesignExample(t *testing.T) {
34+ m, err := Parse("deploy", []byte(sovrn))
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ if got := m.Actions["deploy"].Concurrency; got == nil || got.Group != "fleet" {
39+ t.Errorf("concurrency shorthand: %+v", got)
40+ }
41+ if m.Actions["rollback"].Gate.Prompt != "Roll back sovrn?" || !m.Actions["rollback"].Manual {
42+ t.Errorf("rollback: %+v", m.Actions["rollback"])
43+ }
44+ if i, ok := m.IsStep("health-check"); !ok || i != 3 {
45+ t.Errorf("IsStep(health-check) = %d, %v", i, ok)
46+ }
47+ if _, ok := m.IsStep("rollback"); ok {
48+ t.Error("rollback is not a step")
49+ }
50+ if m.Repos[1].Env != "SECRETS_DIR" {
51+ t.Errorf("repos: %+v", m.Repos)
52+ }
53+}
54+
55+func TestShorthands(t *testing.T) {
56+ m, err := Parse("w", []byte(`
57+when: [{ event: push, branch: main, tag: "v*" }]
58+engine: devenv
59+environment: { A: 1, B: true, C: x }
60+steps: [a]
61+actions:
62+ a: { command: "true", gate: true }
63+ b: { command: "true", gate: }
64+ c: { command: "true", gate: false }
65+`))
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ w := m.When[0]
70+ if len(w.Event) != 1 || w.Event[0] != "push" || w.Branch[0] != "main" || w.Tag[0] != "v*" {
71+ t.Errorf("when: %+v", w)
72+ }
73+ if m.Environment["A"] != "1" || m.Environment["B"] != "true" {
74+ t.Errorf("environment: %+v", m.Environment)
75+ }
76+ if m.Actions["a"].Gate == nil || m.Actions["b"].Gate == nil || m.Actions["c"].Gate != nil {
77+ t.Errorf("gates: a=%v b=%v c=%v", m.Actions["a"].Gate, m.Actions["b"].Gate, m.Actions["c"].Gate)
78+ }
79+ if m.GateTimeout("a") != DefaultGateTimeout {
80+ t.Errorf("default timeout")
81+ }
82+}
83+
84+func TestOtherEngine(t *testing.T) {
85+ _, err := Parse("w", []byte("engine: nixery\nsteps: [{name: x, command: y}]\n"))
86+ if !errors.Is(err, ErrOtherEngine) {
87+ t.Fatalf("got %v", err)
88+ }
89+}
90+
91+func TestInvalid(t *testing.T) {
92+ cases := map[string]string{
93+ "undeclared step": `engine: devenv
94+steps: [a, b]
95+actions: { a: { command: x } }`,
96+ "duplicate step": `engine: devenv
97+steps: [a, a]
98+actions: { a: { command: x } }`,
99+ "no steps": `engine: devenv
100+steps: []
101+actions: { a: { command: x } }`,
102+ "unknown field": `engine: devenv
103+steps: [a]
104+actions: { a: { command: x, comand: y } }`,
105+ "empty command": `engine: devenv
106+steps: [a]
107+actions: { a: { command: " " } }`,
108+ "reserved name": `engine: devenv
109+steps: [a]
110+actions: { a: { command: x }, stop: { command: x } }`,
111+ "bad style": `engine: devenv
112+steps: [a]
113+actions: { a: { command: x, style: loud } }`,
114+ "bad timeout": `engine: devenv
115+steps: [a]
116+actions: { a: { command: x, gate: { timeout: soon } } }`,
117+ "absolute dir": `engine: devenv
118+steps: [a]
119+actions: { a: { command: x, dir: /etc } }`,
120+ "repo at root": `engine: devenv
121+repos: [{ repo: r, path: . }]
122+steps: [a]
123+actions: { a: { command: x } }`,
124+ "bad env name": `engine: devenv
125+repos: [{ repo: r, path: ../r, env: 1X }]
126+steps: [a]
127+actions: { a: { command: x } }`,
128+ "unknown event": `engine: devenv
129+when: [{ event: [pr] }]
130+steps: [a]
131+actions: { a: { command: x } }`,
132+ "bad glob": `engine: devenv
133+when: [{ event: [push], branch: ["["] }]
134+steps: [a]
135+actions: { a: { command: x } }`,
136+ }
137+ for name, src := range cases {
138+ if _, err := Parse("w", []byte(src)); err == nil {
139+ t.Errorf("%s: accepted", name)
140+ } else if errors.Is(err, ErrOtherEngine) {
141+ t.Errorf("%s: %v", name, err)
142+ }
143+ }
144+}
145+
146+func TestErrorsAreJoined(t *testing.T) {
147+ _, err := Parse("w", []byte(`engine: devenv
148+steps: [a, b]
149+actions: { a: { command: "", style: loud } }`))
150+ if err == nil {
151+ t.Fatal("accepted")
152+ }
153+ for _, want := range []string{`"b" is not a declared action`, "command is required", "style"} {
154+ if !strings.Contains(err.Error(), want) {
155+ t.Errorf("error %q lacks %q", err, want)
156+ }
157+ }
158+}
159+
160+func TestMatches(t *testing.T) {
161+ m, err := Parse("w", []byte(`engine: devenv
162+when:
163+ - { event: [push], branch: ["main", "release/**"], tag: ["v*"] }
164+ - { event: [push], branch: ["docs"], paths: ["docs/**"] }
165+steps: [a]
166+actions: { a: { command: x } }`))
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ cases := []struct {
171+ ev Event
172+ want bool
173+ }{
174+ {Event{Kind: "push", Ref: "refs/heads/main"}, true},
175+ {Event{Kind: "push", Ref: "refs/heads/release/1/x"}, true},
176+ {Event{Kind: "push", Ref: "refs/heads/feature"}, false},
177+ {Event{Kind: "push", Ref: "refs/tags/v1.2"}, true},
178+ {Event{Kind: "push", Ref: "refs/tags/main"}, false},
179+ {Event{Kind: "push", Ref: "refs/heads/docs", ChangedFiles: []string{"docs/a/b.md"}}, true},
180+ {Event{Kind: "push", Ref: "refs/heads/docs", ChangedFiles: []string{"src/x.go"}}, false},
181+ {Event{Kind: "push", Ref: "refs/heads/docs"}, true}, // changed files unknown
182+ {Event{Kind: "manual"}, true},
183+ {Event{Kind: "pull_request"}, false},
184+ }
185+ for _, c := range cases {
186+ if got := m.Matches(c.ev); got != c.want {
187+ t.Errorf("%+v: got %v", c.ev, got)
188+ }
189+ }
190+ none, _ := Parse("w", []byte("engine: devenv\nsteps: [a]\nactions: { a: { command: x } }"))
191+ if !none.Matches(Event{Kind: "push", Ref: "refs/heads/x"}) {
192+ t.Error("a workflow without conditions always runs")
193+ }
194+}
195+
196+func TestNameOf(t *testing.T) {
197+ for in, want := range map[string]string{"a/b/deploy.yml": "deploy", "x.yaml": "x", "y": "y"} {
198+ if got := NameOf(in); got != want {
199+ t.Errorf("NameOf(%q) = %q", in, got)
200+ }
201+ }
202+}
+71,
-0
1@@ -0,0 +1,71 @@
2+package manifest
3+
4+import (
5+ "slices"
6+ "strings"
7+
8+ "github.com/bmatcuk/doublestar/v4"
9+)
10+
11+// Event is what a trigger is matched against.
12+type Event struct {
13+ Kind string // push | manual
14+ Ref string // full ref, e.g. refs/heads/main (push)
15+ ChangedFiles []string // push; nil means unknown, which never fails a paths filter
16+}
17+
18+// Matches reports whether the workflow should run for ev, with upstream's
19+// semantics: manual dispatch always matches (the caller picked the
20+// workflow); a workflow with no conditions always runs; otherwise any one
21+// condition must match its event, ref and paths.
22+func (m *Manifest) Matches(ev Event) bool {
23+ if ev.Kind == "manual" {
24+ return true
25+ }
26+ if len(m.When) == 0 {
27+ return true
28+ }
29+ for _, c := range m.When {
30+ if !slices.Contains(c.Event, ev.Kind) {
31+ continue
32+ }
33+ if ev.Kind == "push" && !matchRef(ev.Ref, c.Branch, c.Tag) {
34+ continue
35+ }
36+ if len(c.Paths) > 0 && ev.ChangedFiles != nil && !anyFileMatches(ev.ChangedFiles, c.Paths) {
37+ continue
38+ }
39+ return true
40+ }
41+ return false
42+}
43+
44+func matchRef(ref string, branches, tags []string) bool {
45+ if b, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
46+ return anyMatch(b, branches)
47+ }
48+ if t, ok := strings.CutPrefix(ref, "refs/tags/"); ok {
49+ return anyMatch(t, tags)
50+ }
51+ return false
52+}
53+
54+func anyMatch(name string, patterns []string) bool {
55+ for _, p := range patterns {
56+ if ok, _ := doublestar.Match(p, name); ok {
57+ return true
58+ }
59+ }
60+ return false
61+}
62+
63+func anyFileMatches(files, patterns []string) bool {
64+ for _, f := range files {
65+ if anyMatch(f, patterns) {
66+ return true
67+ }
68+ }
69+ return false
70+}
71+
72+func validGlob(p string) bool { return p != "" && doublestar.ValidatePattern(p) }
+145,
-0
1@@ -0,0 +1,145 @@
2+// Package render prints run events for a terminal: `ci run` live and
3+// `ci view` afterwards render the same events.ndjson the same way.
4+package render
5+
6+import (
7+ "fmt"
8+ "io"
9+ "os"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ "git.kilimanjaro.io/ci/internal/lex"
15+)
16+
17+// Terminal renders events to w.
18+type Terminal struct {
19+ mu sync.Mutex
20+ w io.Writer
21+ color bool
22+ starts map[int]time.Time
23+}
24+
25+// New returns a renderer for w, with colour if w is a terminal and NO_COLOR
26+// is unset.
27+func New(w io.Writer) *Terminal {
28+ color := false
29+ if f, ok := w.(*os.File); ok && os.Getenv("NO_COLOR") == "" {
30+ if fi, err := f.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
31+ color = true
32+ }
33+ }
34+ return &Terminal{w: w, color: color, starts: map[int]time.Time{}}
35+}
36+
37+func (t *Terminal) paint(code, s string) string {
38+ if !t.color {
39+ return s
40+ }
41+ return "\x1b[" + code + "m" + s + "\x1b[0m"
42+}
43+
44+func (t *Terminal) statusColor(status string) string {
45+ switch status {
46+ case lex.Success:
47+ return "32"
48+ case lex.Warning, "paused":
49+ return "33"
50+ case lex.Failure, "cancelled":
51+ return "31"
52+ }
53+ return "0"
54+}
55+
56+// Event renders one event.
57+func (t *Terminal) Event(ev lex.RunEvent) {
58+ t.mu.Lock()
59+ defer t.mu.Unlock()
60+ ts, _ := time.Parse(time.RFC3339Nano, ev.Time)
61+ switch ev.Kind {
62+ case lex.KindRun:
63+ if ev.Status == "running" {
64+ fmt.Fprintf(t.w, "%s %s\n", t.paint("1", "ci"), ev.Content)
65+ return
66+ }
67+ line := fmt.Sprintf("%s %s", t.paint("1", "ci"), t.paint(t.statusColor(ev.Status), ev.Status))
68+ if ev.Content != "" {
69+ line += ": " + ev.Content
70+ }
71+ fmt.Fprintln(t.w, line)
72+ case lex.KindCheckout:
73+ fmt.Fprintf(t.w, " checkout %s @ %s -> %s\n", ev.Repo, short(ev.Rev), ev.Path)
74+ case lex.KindStart:
75+ t.starts[ev.Attempt] = ts
76+ fmt.Fprintf(t.w, "%s %s %s\n", t.paint("1;36", "▶"), t.paint("1", ev.Action), t.paint("2", firstLine(ev.Command)))
77+ case lex.KindLog:
78+ prefix := t.paint("2", "│ ")
79+ if ev.Stream == "stderr" {
80+ prefix = t.paint("33", "│ ")
81+ }
82+ fmt.Fprintln(t.w, prefix+ev.Content)
83+ case lex.KindEnd:
84+ mark := "✔"
85+ switch ev.Status {
86+ case lex.Failure:
87+ mark = "✘"
88+ case lex.Warning:
89+ mark = "!"
90+ }
91+ took := ""
92+ if s, ok := t.starts[ev.Attempt]; ok && !ts.IsZero() {
93+ took = " " + ts.Sub(s).Round(100*time.Millisecond).String()
94+ }
95+ code := ""
96+ if ev.ExitCode != nil && *ev.ExitCode != 0 {
97+ code = fmt.Sprintf(" exit %d", *ev.ExitCode)
98+ }
99+ fmt.Fprintf(t.w, "%s %s %s%s%s\n", t.paint(t.statusColor(ev.Status), mark), ev.Action, t.paint(t.statusColor(ev.Status), ev.Status), code, t.paint("2", took))
100+ case lex.KindResult:
101+ if r := ev.Result; r != nil {
102+ if r.Summary != "" {
103+ for _, l := range strings.Split(strings.TrimRight(r.Summary, "\n"), "\n") {
104+ fmt.Fprintln(t.w, " "+l)
105+ }
106+ }
107+ for _, a := range r.Attachments {
108+ label := a.Label
109+ if label == "" {
110+ label = a.Path
111+ }
112+ fmt.Fprintf(t.w, " attachment: %s (%s)\n", label, a.Path)
113+ }
114+ }
115+ case lex.KindTransition:
116+ if tr := ev.Transition; tr != nil && tr.To != "" && tr.Reason != lex.ReasonDefault && tr.Reason != lex.ReasonStart {
117+ fmt.Fprintf(t.w, "%s %s (%s)\n", t.paint("2", "→"), tr.To, tr.Reason)
118+ }
119+ case lex.KindGate:
120+ if g := ev.Gate; g != nil {
121+ fmt.Fprintf(t.w, "%s gate %s", t.paint("1;33", "⏸"), g.State)
122+ if g.Choice != "" {
123+ fmt.Fprintf(t.w, ": %s", g.Choice)
124+ }
125+ if g.Actor != "" {
126+ fmt.Fprintf(t.w, " by %s", g.Actor)
127+ }
128+ fmt.Fprintln(t.w)
129+ }
130+ }
131+}
132+
133+func firstLine(s string) string {
134+ s = strings.TrimSpace(s)
135+ if i := strings.IndexByte(s, '\n'); i >= 0 {
136+ return s[:i] + " …"
137+ }
138+ return s
139+}
140+
141+func short(s string) string {
142+ if len(s) > 12 {
143+ return s[:12]
144+ }
145+ return s
146+}
+173,
-0
1@@ -0,0 +1,173 @@
2+// Package runlog reads and writes a run's events.ndjson: one
3+// run.rtw.ci.event per line, the single observable format shared by the
4+// terminal renderer, `ci view`, the runner's streams and the appview.
5+package runlog
6+
7+import (
8+ "bufio"
9+ "bytes"
10+ "encoding/json"
11+ "fmt"
12+ "io"
13+ "os"
14+ "sync"
15+ "time"
16+
17+ "git.kilimanjaro.io/ci/internal/lex"
18+)
19+
20+// Writer appends events to an events.ndjson file. It is safe for concurrent
21+// use (an action's stdout and stderr are written from two goroutines).
22+type Writer struct {
23+ mu sync.Mutex
24+ f *os.File
25+ run string
26+ seq int
27+ sinks []func(lex.RunEvent)
28+ now func() time.Time
29+}
30+
31+// Open opens (or creates) path for appending, continuing the sequence of
32+// any events already in it, so a resumed run extends its own log.
33+func Open(path, run string) (*Writer, error) {
34+ n, err := countLines(path)
35+ if err != nil {
36+ return nil, err
37+ }
38+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
39+ if err != nil {
40+ return nil, err
41+ }
42+ return &Writer{f: f, run: run, seq: n, now: time.Now}, nil
43+}
44+
45+func countLines(path string) (int, error) {
46+ f, err := os.Open(path)
47+ if os.IsNotExist(err) {
48+ return 0, nil
49+ }
50+ if err != nil {
51+ return 0, err
52+ }
53+ defer f.Close()
54+ n := 0
55+ buf := make([]byte, 64*1024)
56+ for {
57+ c, err := f.Read(buf)
58+ n += bytes.Count(buf[:c], []byte{'\n'})
59+ if err == io.EOF {
60+ return n, nil
61+ }
62+ if err != nil {
63+ return 0, err
64+ }
65+ }
66+}
67+
68+// AddSink registers a function called with every event after it is written.
69+func (w *Writer) AddSink(f func(lex.RunEvent)) {
70+ w.mu.Lock()
71+ defer w.mu.Unlock()
72+ w.sinks = append(w.sinks, f)
73+}
74+
75+// Emit stamps ev with the next sequence number, the time and the run id, and
76+// appends it.
77+func (w *Writer) Emit(ev lex.RunEvent) error {
78+ w.mu.Lock()
79+ defer w.mu.Unlock()
80+ w.seq++
81+ ev.Seq = w.seq
82+ ev.Time = w.now().UTC().Format(time.RFC3339Nano)
83+ ev.Run = w.run
84+ b, err := json.Marshal(ev)
85+ if err != nil {
86+ return err
87+ }
88+ if _, err := w.f.Write(append(b, '\n')); err != nil {
89+ return err
90+ }
91+ for _, s := range w.sinks {
92+ s(ev)
93+ }
94+ return nil
95+}
96+
97+// Close closes the file.
98+func (w *Writer) Close() error { return w.f.Close() }
99+
100+// Lines returns an io.WriteCloser that emits one log event per line written
101+// to it, for an action's stdout or stderr. Close flushes a final partial line.
102+func (w *Writer) Lines(action string, attempt int, stream string) io.WriteCloser {
103+ return &lineWriter{w: w, action: action, attempt: attempt, stream: stream}
104+}
105+
106+type lineWriter struct {
107+ w *Writer
108+ action string
109+ attempt int
110+ stream string
111+ buf []byte
112+}
113+
114+func (l *lineWriter) Write(p []byte) (int, error) {
115+ l.buf = append(l.buf, p...)
116+ for {
117+ i := bytes.IndexByte(l.buf, '\n')
118+ if i < 0 {
119+ break
120+ }
121+ if err := l.emit(l.buf[:i]); err != nil {
122+ return 0, err
123+ }
124+ l.buf = l.buf[i+1:]
125+ }
126+ return len(p), nil
127+}
128+
129+func (l *lineWriter) emit(line []byte) error {
130+ return l.w.Emit(lex.RunEvent{
131+ Kind: lex.KindLog,
132+ Action: l.action,
133+ Attempt: l.attempt,
134+ Stream: l.stream,
135+ Content: string(bytes.TrimSuffix(line, []byte{'\r'})),
136+ })
137+}
138+
139+func (l *lineWriter) Close() error {
140+ if len(l.buf) == 0 {
141+ return nil
142+ }
143+ err := l.emit(l.buf)
144+ l.buf = nil
145+ return err
146+}
147+
148+// Read decodes every event in r.
149+func Read(r io.Reader) ([]lex.RunEvent, error) {
150+ var out []lex.RunEvent
151+ err := Scan(r, func(ev lex.RunEvent) error { out = append(out, ev); return nil })
152+ return out, err
153+}
154+
155+// Scan calls f for each event in r, in order.
156+func Scan(r io.Reader, f func(lex.RunEvent) error) error {
157+ sc := bufio.NewScanner(r)
158+ sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
159+ line := 0
160+ for sc.Scan() {
161+ line++
162+ if len(bytes.TrimSpace(sc.Bytes())) == 0 {
163+ continue
164+ }
165+ var ev lex.RunEvent
166+ if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
167+ return fmt.Errorf("line %d: %w", line, err)
168+ }
169+ if err := f(ev); err != nil {
170+ return err
171+ }
172+ }
173+ return sc.Err()
174+}
+41,
-0
1@@ -0,0 +1,41 @@
2+// Package tid generates ATProto TIDs: 13-character, base32-sortable record
3+// keys from a microsecond timestamp and a 10-bit clock id. Used for record
4+// keys and local run ids.
5+package tid
6+
7+import (
8+ "math/rand/v2"
9+ "sync"
10+ "time"
11+)
12+
13+const alphabet = "234567abcdefghijklmnopqrstuvwxyz"
14+
15+var (
16+ mu sync.Mutex
17+ last int64
18+ clockID = rand.Int64N(1024)
19+)
20+
21+// New returns a TID greater than any previously returned by this process.
22+func New() string {
23+ mu.Lock()
24+ us := time.Now().UnixMicro()
25+ if us <= last {
26+ us = last + 1
27+ }
28+ last = us
29+ mu.Unlock()
30+ return Encode(us, clockID)
31+}
32+
33+// Encode formats a TID from microseconds since the epoch and a clock id.
34+func Encode(us, clock int64) string {
35+ v := uint64(us&(1<<53-1))<<10 | uint64(clock&1023)
36+ var b [13]byte
37+ for i := 12; i >= 0; i-- {
38+ b[i] = alphabet[v&31]
39+ v >>= 5
40+ }
41+ return string(b[:])
42+}
+24,
-0
1@@ -0,0 +1,24 @@
2+package tid
3+
4+import "testing"
5+
6+func TestSortableAndValid(t *testing.T) {
7+ prev := ""
8+ for range 1000 {
9+ s := New()
10+ if len(s) != 13 || s[0] > 'j' { // top bit is zero: first char is 2..j
11+ t.Fatalf("bad tid %q", s)
12+ }
13+ if s <= prev {
14+ t.Fatalf("%q not after %q", s, prev)
15+ }
16+ prev = s
17+ }
18+}
19+
20+func TestKnownValue(t *testing.T) {
21+ // Checked against indigo atproto/syntax.NewTID(1682452732371000, 0).
22+ if got := Encode(1682452732371000, 0); got != "3ju7uarhs3s22" {
23+ t.Errorf("got %s", got)
24+ }
25+}
+68,
-0
1@@ -0,0 +1,68 @@
2+// Package vcs finds the repository a local run happens in and what commit
3+// its working copy is, for jj (preferred when present) and git.
4+package vcs
5+
6+import (
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+)
13+
14+// Root returns the nearest directory at or above start that contains .jj or
15+// .git.
16+func Root(start string) (string, error) {
17+ dir, err := filepath.Abs(start)
18+ if err != nil {
19+ return "", err
20+ }
21+ for {
22+ for _, marker := range []string{".jj", ".git"} {
23+ if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
24+ return dir, nil
25+ }
26+ }
27+ parent := filepath.Dir(dir)
28+ if parent == dir {
29+ return "", fmt.Errorf("%s is not inside a jj or git repository", start)
30+ }
31+ dir = parent
32+ }
33+}
34+
35+// Head returns the working copy's commit and whether it has changes. With
36+// jj that is @ (jj snapshots the working copy first, as every jj command
37+// does); with git it is HEAD plus `git status`.
38+func Head(root string) (sha string, dirty bool, err error) {
39+ if _, err := os.Stat(filepath.Join(root, ".jj")); err == nil {
40+ out, err := output(root, "jj", "log", "--no-graph", "-r", "@", "-T", `commit_id ++ " " ++ empty`)
41+ if err != nil {
42+ return "", false, err
43+ }
44+ id, empty, _ := strings.Cut(strings.TrimSpace(out), " ")
45+ return id, empty != "true", nil
46+ }
47+ sha, err = output(root, "git", "rev-parse", "HEAD")
48+ if err != nil {
49+ return "", false, err
50+ }
51+ st, err := output(root, "git", "status", "--porcelain")
52+ if err != nil {
53+ return "", false, err
54+ }
55+ return strings.TrimSpace(sha), strings.TrimSpace(st) != "", nil
56+}
57+
58+func output(dir, name string, args ...string) (string, error) {
59+ cmd := exec.Command(name, args...)
60+ cmd.Dir = dir
61+ out, err := cmd.Output()
62+ if err != nil {
63+ if ee, ok := err.(*exec.ExitError); ok {
64+ return "", fmt.Errorf("%s %s: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(ee.Stderr)))
65+ }
66+ return "", fmt.Errorf("%s: %w", name, err)
67+ }
68+ return string(out), nil
69+}
+82,
-18
1@@ -5,25 +5,89 @@
2 "defs": {
3 "main": {
4 "type": "object",
5- "required": ["seq", "time", "kind", "run"],
6+ "required": [
7+ "seq",
8+ "time",
9+ "kind",
10+ "run"
11+ ],
12 "properties": {
13- "seq": { "type": "integer", "description": "Line number in the file, from 1." },
14- "time": { "type": "string", "format": "datetime" },
15- "run": { "type": "string" },
16- "kind": { "type": "string", "knownValues": ["run", "checkout", "start", "log", "end", "result", "transition", "gate"] },
17- "action": { "type": "string" },
18- "attempt": { "type": "integer", "description": "Execution number of the action within the run (1, 2, ... for repeats); also the log step id." },
19- "stream": { "type": "string", "knownValues": ["stdout", "stderr"] },
20- "content": { "type": "string", "description": "A log line (kind log), a message (run, checkout)." },
21- "command": { "type": "string" },
22- "exitCode": { "type": "integer" },
23- "status": { "type": "string", "description": "kind run: pending|running|paused|success|failure|cancelled; kind end: the action's status." },
24- "repo": { "type": "string", "description": "kind checkout: repo name." },
25- "rev": { "type": "string", "description": "kind checkout: checked-out commit." },
26- "path": { "type": "string", "description": "kind checkout: checkout path." },
27- "result": { "type": "ref", "ref": "run.rtw.ci.step.result" },
28- "transition": { "type": "ref", "ref": "run.rtw.ci.transition" },
29- "gate": { "type": "ref", "ref": "run.rtw.ci.gate" }
30+ "seq": {
31+ "type": "integer",
32+ "description": "Line number in the file, from 1."
33+ },
34+ "time": {
35+ "type": "string",
36+ "format": "datetime"
37+ },
38+ "run": {
39+ "type": "string"
40+ },
41+ "kind": {
42+ "type": "string",
43+ "knownValues": [
44+ "run",
45+ "checkout",
46+ "start",
47+ "log",
48+ "end",
49+ "result",
50+ "transition",
51+ "gate"
52+ ]
53+ },
54+ "action": {
55+ "type": "string"
56+ },
57+ "attempt": {
58+ "type": "integer",
59+ "description": "Execution number within the run (1, 2, ...), counting repeats of the same action separately; also the log step id."
60+ },
61+ "stream": {
62+ "type": "string",
63+ "knownValues": [
64+ "stdout",
65+ "stderr"
66+ ]
67+ },
68+ "content": {
69+ "type": "string",
70+ "description": "A log line (kind log), a message (run, checkout)."
71+ },
72+ "command": {
73+ "type": "string"
74+ },
75+ "exitCode": {
76+ "type": "integer"
77+ },
78+ "status": {
79+ "type": "string",
80+ "description": "kind run: pending|running|paused|success|failure|cancelled; kind end: the action's status."
81+ },
82+ "repo": {
83+ "type": "string",
84+ "description": "kind checkout: repo name."
85+ },
86+ "rev": {
87+ "type": "string",
88+ "description": "kind checkout: checked-out commit."
89+ },
90+ "path": {
91+ "type": "string",
92+ "description": "kind checkout: checkout path."
93+ },
94+ "result": {
95+ "type": "ref",
96+ "ref": "run.rtw.ci.step.result"
97+ },
98+ "transition": {
99+ "type": "ref",
100+ "ref": "run.rtw.ci.transition"
101+ },
102+ "gate": {
103+ "type": "ref",
104+ "ref": "run.rtw.ci.gate"
105+ }
106 }
107 }
108 }
+60,
-11
1@@ -5,18 +5,67 @@
2 "defs": {
3 "main": {
4 "type": "object",
5- "required": ["run", "workflow", "seq", "reason", "createdAt"],
6+ "required": [
7+ "run",
8+ "workflow",
9+ "seq",
10+ "reason",
11+ "createdAt"
12+ ],
13 "properties": {
14- "pipeline": { "type": "string", "format": "at-uri" },
15- "run": { "type": "string" },
16- "workflow": { "type": "string" },
17- "seq": { "type": "integer" },
18- "from": { "type": "string", "description": "Action that just finished; absent at the start of a run." },
19- "to": { "type": "string", "description": "Action that runs next; absent when the run ends." },
20- "then": { "type": "string", "knownValues": ["continue", "stop"] },
21- "reason": { "type": "string", "knownValues": ["start", "default", "next", "choice", "manual", "stop", "dismiss", "failure", "error", "limit"] },
22- "detail": { "type": "string" },
23- "createdAt": { "type": "string", "format": "datetime" }
24+ "pipeline": {
25+ "type": "string",
26+ "format": "at-uri"
27+ },
28+ "run": {
29+ "type": "string"
30+ },
31+ "workflow": {
32+ "type": "string"
33+ },
34+ "seq": {
35+ "type": "integer"
36+ },
37+ "from": {
38+ "type": "string",
39+ "description": "Action that just finished; absent at the start of a run."
40+ },
41+ "to": {
42+ "type": "string",
43+ "description": "Action that runs next; absent when the run ends."
44+ },
45+ "then": {
46+ "type": "string",
47+ "knownValues": [
48+ "continue",
49+ "stop"
50+ ]
51+ },
52+ "reason": {
53+ "type": "string",
54+ "knownValues": [
55+ "start",
56+ "default",
57+ "next",
58+ "choice",
59+ "manual",
60+ "stop",
61+ "dismiss",
62+ "failure",
63+ "error",
64+ "limit",
65+ "expired",
66+ "until",
67+ "cancelled"
68+ ]
69+ },
70+ "detail": {
71+ "type": "string"
72+ },
73+ "createdAt": {
74+ "type": "string",
75+ "format": "datetime"
76+ }
77 }
78 }
79 }
+122,
-28
1@@ -5,17 +5,27 @@
2 "defs": {
3 "main": {
4 "type": "object",
5- "required": ["when", "engine", "steps", "actions"],
6+ "required": [
7+ "when",
8+ "engine",
9+ "steps",
10+ "actions"
11+ ],
12 "properties": {
13 "when": {
14 "type": "array",
15 "description": "Trigger conditions, as upstream's workflow files. Matched by the runner on a ref update or manual trigger.",
16- "items": { "type": "ref", "ref": "#trigger" }
17+ "items": {
18+ "type": "ref",
19+ "ref": "#trigger"
20+ }
21 },
22 "engine": {
23 "type": "string",
24 "description": "Must be devenv. Upstream spindles ignore workflows for engines they don't run.",
25- "knownValues": ["devenv"]
26+ "knownValues": [
27+ "devenv"
28+ ]
29 },
30 "environment": {
31 "type": "unknown",
32@@ -24,13 +34,18 @@
33 "repos": {
34 "type": "array",
35 "description": "Extra repositories checked out next to the main one for each run.",
36- "items": { "type": "ref", "ref": "#repo" }
37+ "items": {
38+ "type": "ref",
39+ "ref": "#repo"
40+ }
41 },
42 "steps": {
43 "type": "array",
44 "description": "The happy path: action names in order.",
45 "minLength": 1,
46- "items": { "type": "string" }
47+ "items": {
48+ "type": "string"
49+ }
50 },
51 "actions": {
52 "type": "unknown",
53@@ -40,62 +55,141 @@
54 },
55 "trigger": {
56 "type": "object",
57- "required": ["event"],
58+ "required": [
59+ "event"
60+ ],
61 "properties": {
62 "event": {
63 "type": "array",
64 "description": "In YAML a single string is accepted too.",
65- "items": { "type": "string", "knownValues": ["push", "manual", "pull_request", "schedule"] }
66+ "items": {
67+ "type": "string",
68+ "knownValues": [
69+ "push",
70+ "manual",
71+ "pull_request",
72+ "schedule"
73+ ]
74+ }
75 },
76 "branch": {
77 "type": "array",
78 "description": "Branch globs (* and **). In YAML a single string is accepted too.",
79- "items": { "type": "string" }
80+ "items": {
81+ "type": "string"
82+ }
83 },
84 "tag": {
85 "type": "array",
86 "description": "Tag globs (* and **), push events only. In YAML a single string is accepted too.",
87- "items": { "type": "string" }
88+ "items": {
89+ "type": "string"
90+ }
91+ },
92+ "paths": {
93+ "type": "array",
94+ "description": "Push only: run only if a changed file matches one of these globs (* and **), as upstream. In YAML a single string is accepted too.",
95+ "items": {
96+ "type": "string"
97+ }
98 }
99 }
100 },
101 "repo": {
102 "type": "object",
103- "required": ["repo", "path"],
104+ "required": [
105+ "repo",
106+ "path"
107+ ],
108 "properties": {
109- "repo": { "type": "string", "description": "Repository name on the forge (resolved to a clone URL by configuration)." },
110- "path": { "type": "string", "description": "Checkout path relative to the main checkout, e.g. ../servers." },
111- "rev": { "type": "string", "description": "Revision to check out. Default: the forge's default branch at the time the run starts." },
112- "env": { "type": "string", "description": "Environment variable that receives the checkout's absolute path, e.g. SECRETS_DIR." }
113+ "repo": {
114+ "type": "string",
115+ "description": "Repository name on the forge (resolved to a clone URL by configuration)."
116+ },
117+ "path": {
118+ "type": "string",
119+ "description": "Checkout path relative to the main checkout, e.g. ../servers."
120+ },
121+ "rev": {
122+ "type": "string",
123+ "description": "Revision to check out. Default: the forge's default branch at the time the run starts."
124+ },
125+ "env": {
126+ "type": "string",
127+ "description": "Environment variable that receives the checkout's absolute path, e.g. SECRETS_DIR."
128+ }
129 }
130 },
131 "action": {
132 "type": "object",
133- "required": ["command"],
134+ "required": [
135+ "command"
136+ ],
137 "properties": {
138- "command": { "type": "string", "description": "Run as `devenv shell -- bash -euo pipefail -c <command>`." },
139- "dir": { "type": "string", "description": "Working directory relative to the main checkout. Default: the main checkout." },
140- "label": { "type": "string", "description": "Button and timeline label. Default: the action name." },
141- "gate": { "type": "ref", "ref": "#gate", "description": "Always ask a human before running. In YAML `gate: true` is accepted too." },
142- "concurrency": { "type": "ref", "ref": "#concurrency", "description": "In YAML a plain group name is accepted too." },
143- "manual": { "type": "boolean", "description": "Offer as a button on the run page at any time." },
144- "style": { "type": "string", "knownValues": ["normal", "danger"] },
145- "audited": { "type": "boolean", "description": "Local runs that execute this action report to the appview." }
146+ "command": {
147+ "type": "string",
148+ "description": "Run as `devenv shell -- bash -euo pipefail -c <command>`."
149+ },
150+ "dir": {
151+ "type": "string",
152+ "description": "Working directory relative to the main checkout. Default: the main checkout."
153+ },
154+ "label": {
155+ "type": "string",
156+ "description": "Button and timeline label. Default: the action name."
157+ },
158+ "gate": {
159+ "type": "ref",
160+ "ref": "#gate",
161+ "description": "Always ask a human before running. In YAML `gate: true` is accepted too."
162+ },
163+ "concurrency": {
164+ "type": "ref",
165+ "ref": "#concurrency",
166+ "description": "In YAML a plain group name is accepted too."
167+ },
168+ "manual": {
169+ "type": "boolean",
170+ "description": "Offer as a button on the run page at any time."
171+ },
172+ "style": {
173+ "type": "string",
174+ "knownValues": [
175+ "normal",
176+ "danger"
177+ ]
178+ },
179+ "audited": {
180+ "type": "boolean",
181+ "description": "Local runs that execute this action report to the appview."
182+ }
183 }
184 },
185 "gate": {
186 "type": "object",
187 "properties": {
188- "prompt": { "type": "string" },
189- "timeout": { "type": "string", "description": "Go duration, e.g. 24h. Default 24h." }
190+ "prompt": {
191+ "type": "string"
192+ },
193+ "timeout": {
194+ "type": "string",
195+ "description": "Go duration, e.g. 24h. Default 24h."
196+ }
197 }
198 },
199 "concurrency": {
200 "type": "object",
201- "required": ["group"],
202+ "required": [
203+ "group"
204+ ],
205 "properties": {
206- "group": { "type": "string" },
207- "supersede": { "type": "boolean", "description": "A newer run reaching this group's gate supersedes older runs waiting at it." }
208+ "group": {
209+ "type": "string"
210+ },
211+ "supersede": {
212+ "type": "boolean",
213+ "description": "A newer run reaching this group's gate supersedes older runs waiting at it."
214+ }
215 }
216 }
217 }