25 files changed,
+1990,
-5
A
go.mod
+1,
-1
1@@ -2,7 +2,7 @@
2 .devenv*
3 devenv.local.nix
4 devenv.local.yaml
5-result*
6+/result*
7
8 # direnv
9 .direnv/
+6,
-2
1@@ -101,8 +101,11 @@ split across hosts later.
2 envelope is upstream's `eventstream.Event`: `{rkey, nsid, event, created}`,
3 where `created` is a monotonic local clock value and doubles as the cursor.
4 - `GET /logs/{knot}/{rkey}/{workflow}` — replay the log, then tail (runner).
5- Lines are upstream's `sh.tangled.ci.subscribePipelineLogs` `#control` /
6- `#data` objects.
7+ Messages are upstream's `spindle/models.LogLine` JSON (`kind: data |
8+ control`, `content`, `time`, `step_id`, `stream`, `step_status`,
9+ `step_kind`, `step_command`), which the upstream appview reads. (The
10+ newer `sh.tangled.ci.subscribePipelineLogs` XRPC sends the same content as
11+ CBOR `#control`/`#data` frames; not implemented.)
12 - Tangled lexicons used as-is (checked against tangled core `e0ef7981d`,
13 2026-10-11):
14 - `sh.tangled.git.refUpdate` — emitted by the bridge, as a knot does
15@@ -123,6 +126,7 @@ split across hosts later.
16 - `run.rtw.ci.gate` — `{pipeline, workflow, action, options[], state:
17 waiting|approved|rejected|expired|superseded, actor, prompt}`
18 - `run.rtw.ci.transition` — engine routing decisions, for the timeline
19+ - `run.rtw.ci.event` — one line of a run's `events.ndjson`
20 Tangled clients ignore unknown NSIDs, so nothing breaks.
21 - Lexicons are the single schema source. Go types are hand-written for the
22 handful of records used (Tangled's `api/tangled` package pulls in the whole
+42,
-0
1@@ -0,0 +1,42 @@
2+# ci
3+
4+A Nix-native CI/CD system that speaks Tangled's protocol: a knot-compatible
5+bridge for a soft-serve forge, a spindle-compatible runner, a minimal appview,
6+and `ci`, the engine that runs the same workflow on your machine and on the
7+runner. The design, and the reasons behind it, are in [DESIGN.md](DESIGN.md);
8+work is tracked with `bug ls` (tracking issue `eae4f87`).
9+
10+## Layout
11+
12+| Path | What |
13+|---|---|
14+| `cmd/ci` | The `ci` binary (engine commands and the three hosts); `ci-result` is a symlink to it |
15+| `internal/lex` | Go types for the lexicons, checked against the JSON by `go test` |
16+| `lexicons/ci` | Our lexicons, `run.rtw.ci.*`: workflow manifest, step result, gate, transition, run event |
17+| `lexicons/tangled` | Vendored Tangled lexicons (see its README) |
18+
19+## Development
20+
21+```sh
22+devenv shell # Go toolchain, golangci-lint, just
23+go test ./...
24+nix build .#ci # ./result/bin/ci
25+```
26+
27+## Using the engine in another repo
28+
29+Add the flake as a devenv input and put the package on `PATH`:
30+
31+```yaml
32+# devenv.yaml
33+inputs:
34+ ci:
35+ url: git+ssh://[email protected]/ci
36+```
37+
38+```nix
39+# devenv.nix
40+{ pkgs, inputs, ... }: {
41+ packages = [ inputs.ci.packages.${pkgs.stdenv.system}.ci ];
42+}
43+```
+57,
-0
1@@ -0,0 +1,57 @@
2+// Command ci is the CI/CD engine (run, resume, action, validate, view, ...)
3+// and the three hosts around it (bridge, runner, appview). See DESIGN.md.
4+//
5+// Invoked as ci-result (a symlink), it is the result helper (`ci result`).
6+package main
7+
8+import (
9+ "fmt"
10+ "os"
11+ "path/filepath"
12+ "sort"
13+)
14+
15+// version is set by the Nix build (-ldflags -X main.version=...).
16+var version = "dev"
17+
18+type command struct {
19+ summary string
20+ run func(args []string) int
21+}
22+
23+var commands = map[string]command{
24+ "version": {"print the engine version", func([]string) int { fmt.Println(version); return 0 }},
25+}
26+
27+func usage() {
28+ fmt.Fprintln(os.Stderr, "usage: ci <command> [args]\n\ncommands:")
29+ names := make([]string, 0, len(commands))
30+ for n := range commands {
31+ names = append(names, n)
32+ }
33+ sort.Strings(names)
34+ for _, n := range names {
35+ fmt.Fprintf(os.Stderr, " %-10s %s\n", n, commands[n].summary)
36+ }
37+}
38+
39+func main() {
40+ args := os.Args[1:]
41+ if filepath.Base(os.Args[0]) == "ci-result" {
42+ args = append([]string{"result"}, args...)
43+ }
44+ if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
45+ usage()
46+ if len(args) == 0 {
47+ os.Exit(2)
48+ }
49+ return
50+ }
51+ c, ok := commands[args[0]]
52+ if !ok {
53+ fmt.Fprintf(os.Stderr, "ci: unknown command %q\n", args[0])
54+ usage()
55+ os.Exit(2)
56+ }
57+ os.Exit(c.run(args[1:]))
58+}
+31,
-0
1@@ -0,0 +1,31 @@
2+package main
3+
4+import (
5+ "fmt"
6+ "os"
7+)
8+
9+// Commands not implemented yet; each issue in the tracker replaces its stub.
10+func init() {
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+ } {
24+ if _, ok := commands[name]; ok {
25+ continue
26+ }
27+ commands[name] = command{summary, func([]string) int {
28+ fmt.Fprintf(os.Stderr, "ci %s: not implemented yet\n", name)
29+ return 2
30+ }}
31+ }
32+}
+6,
-2
1@@ -1,11 +1,15 @@
2-{ ... }:
3+{ pkgs, ... }:
4
5 # Scaffold for a new project. devkit (imported in devenv.yaml) already provides
6 # bug, secrets, local-code-review, jujutsu, and the customized interactive bash
7 # environment, so don't repeat those here.
8 {
9+ languages.go.enable = true;
10+
11 packages = [
12- # project-specific packages
13+ pkgs.golangci-lint
14+ pkgs.just
15+ pkgs.jq
16 ];
17
18 # enterShell = ''
+27,
-0
1@@ -0,0 +1,27 @@
2+{
3+ "nodes": {
4+ "nixpkgs": {
5+ "locked": {
6+ "lastModified": 1791956154,
7+ "narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=",
8+ "owner": "NixOS",
9+ "repo": "nixpkgs",
10+ "rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044",
11+ "type": "github"
12+ },
13+ "original": {
14+ "owner": "NixOS",
15+ "ref": "nixos-unstable",
16+ "repo": "nixpkgs",
17+ "type": "github"
18+ }
19+ },
20+ "root": {
21+ "inputs": {
22+ "nixpkgs": "nixpkgs"
23+ }
24+ }
25+ },
26+ "root": "root",
27+ "version": 7
28+}
+61,
-0
1@@ -0,0 +1,61 @@
2+# ci: a Nix-native, Tangled-compatible CI/CD engine and its hosts (bridge,
3+# runner, appview). See DESIGN.md.
4+#
5+# Other repos pin the engine through their devenv (see README.md), so local
6+# runs and the runner use the same engine for a given commit.
7+{
8+ description = "Nix-native Tangled-compatible CI/CD";
9+
10+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
11+
12+ outputs =
13+ { self, nixpkgs }:
14+ let
15+ inherit (nixpkgs) lib;
16+ systems = [
17+ "x86_64-linux"
18+ "aarch64-linux"
19+ ];
20+ forAllSystems = f: lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
21+ version = self.shortRev or self.dirtyShortRev or "dev";
22+ in
23+ {
24+ packages = forAllSystems (pkgs: rec {
25+ ci = pkgs.buildGoModule {
26+ pname = "ci";
27+ inherit version;
28+ src = lib.fileset.toSource {
29+ root = ./.;
30+ fileset = lib.fileset.unions [
31+ ./go.mod
32+ (lib.fileset.maybeMissing ./go.sum)
33+ ./cmd
34+ ./internal
35+ ./lexicons
36+ ];
37+ };
38+ vendorHash = null;
39+ subPackages = [ "cmd/ci" ];
40+ env.CGO_ENABLED = "0";
41+ ldflags = [ "-X main.version=${version}" ];
42+ postInstall = ''
43+ ln -s ci $out/bin/ci-result
44+ '';
45+ meta = {
46+ description = "Nix-native Tangled-compatible CI/CD engine";
47+ mainProgram = "ci";
48+ license = lib.licenses.mit;
49+ };
50+ };
51+ default = ci;
52+ });
53+
54+ overlays.default = final: _prev: {
55+ ci = self.packages.${final.stdenv.hostPlatform.system}.ci;
56+ };
57+
58+ checks = forAllSystems (pkgs: {
59+ ci = self.packages.${pkgs.stdenv.hostPlatform.system}.ci.overrideAttrs { doCheck = true; };
60+ });
61+ };
62+}
A
go.mod
+3,
-0
1@@ -0,0 +1,3 @@
2+module git.kilimanjaro.io/ci
3+
4+go 1.26
+276,
-0
1@@ -0,0 +1,276 @@
2+package lex
3+
4+import (
5+ "bytes"
6+ "encoding/json"
7+ "fmt"
8+)
9+
10+const (
11+ WorkflowNSID = "run.rtw.ci.workflow"
12+ ResultNSID = "run.rtw.ci.step.result"
13+ GateNSID = "run.rtw.ci.gate"
14+ TransitionNSID = "run.rtw.ci.transition"
15+ EventNSID = "run.rtw.ci.event"
16+
17+ // ResultVersion is the result contract version this engine speaks; it
18+ // also versions the CI_* environment contract.
19+ ResultVersion = 1
20+
21+ EngineDevenv = "devenv"
22+)
23+
24+// Workflow is run.rtw.ci.workflow, the manifest. YAML manifests are decoded
25+// by package manifest, which normalises shorthands into this shape.
26+type Workflow struct {
27+ When []Trigger `json:"when"`
28+ Engine string `json:"engine"`
29+ Environment map[string]string `json:"environment,omitempty"`
30+ Repos []Repo `json:"repos,omitempty"`
31+ Steps []string `json:"steps"`
32+ Actions map[string]Action `json:"actions"`
33+}
34+
35+type Trigger struct {
36+ Event []string `json:"event"`
37+ Branch []string `json:"branch,omitempty"`
38+ Tag []string `json:"tag,omitempty"`
39+}
40+
41+type Repo struct {
42+ Repo string `json:"repo"`
43+ Path string `json:"path"`
44+ Rev string `json:"rev,omitempty"`
45+ Env string `json:"env,omitempty"`
46+}
47+
48+type Action struct {
49+ Command string `json:"command"`
50+ Dir string `json:"dir,omitempty"`
51+ Label string `json:"label,omitempty"`
52+ Gate *Gate `json:"gate,omitempty"`
53+ Concurrency *Concurrency `json:"concurrency,omitempty"`
54+ Manual bool `json:"manual,omitempty"`
55+ Style string `json:"style,omitempty"`
56+ Audited bool `json:"audited,omitempty"`
57+}
58+
59+// Gate is run.rtw.ci.workflow#gate.
60+type Gate struct {
61+ Prompt string `json:"prompt,omitempty"`
62+ Timeout string `json:"timeout,omitempty"`
63+}
64+
65+type Concurrency struct {
66+ Group string `json:"group"`
67+ Supersede bool `json:"supersede,omitempty"`
68+}
69+
70+// Result statuses.
71+const (
72+ Success = "success"
73+ Warning = "warning"
74+ Failure = "failure"
75+)
76+
77+// Option.Then values.
78+const (
79+ ThenContinue = "continue"
80+ ThenStop = "stop"
81+)
82+
83+// Result is run.rtw.ci.step.result, written by an action to $CI_RESULT.
84+type Result struct {
85+ Version int `json:"version"`
86+ Status string `json:"status"`
87+ Summary string `json:"summary,omitempty"`
88+ Next *Next `json:"next,omitempty"`
89+ Gated bool `json:"gated,omitempty"`
90+ Attachments []Attachment `json:"attachments,omitempty"`
91+ Outputs json.RawMessage `json:"outputs,omitempty"`
92+}
93+
94+// Next is a result's next field. On the wire it is "stop", an action name, a
95+// single option object or an array of options; it always marshals to "stop"
96+// or an array.
97+type Next struct {
98+ Stop bool
99+ Options []Option
100+}
101+
102+func (n Next) MarshalJSON() ([]byte, error) {
103+ if n.Stop {
104+ return []byte(`"stop"`), nil
105+ }
106+ if n.Options == nil {
107+ return []byte(`[]`), nil
108+ }
109+ return json.Marshal(n.Options)
110+}
111+
112+func (n *Next) UnmarshalJSON(b []byte) error {
113+ b = bytes.TrimSpace(b)
114+ *n = Next{}
115+ switch {
116+ case len(b) == 0:
117+ return fmt.Errorf("empty next")
118+ case b[0] == '"':
119+ var s string
120+ if err := json.Unmarshal(b, &s); err != nil {
121+ return err
122+ }
123+ if s == "" {
124+ return fmt.Errorf("next: empty action name")
125+ }
126+ if s == ThenStop {
127+ n.Stop = true
128+ return nil
129+ }
130+ n.Options = []Option{{Action: s}}
131+ return nil
132+ case b[0] == '{':
133+ var o Option
134+ if err := strictUnmarshal(b, &o); err != nil {
135+ return fmt.Errorf("next: %w", err)
136+ }
137+ n.Options = []Option{o}
138+ return nil
139+ case b[0] == '[':
140+ var raw []json.RawMessage
141+ if err := json.Unmarshal(b, &raw); err != nil {
142+ return fmt.Errorf("next: %w", err)
143+ }
144+ if len(raw) == 0 {
145+ return fmt.Errorf("next: empty list (omit next for default routing, or use \"stop\")")
146+ }
147+ for i, r := range raw {
148+ var o Option
149+ if err := strictUnmarshal(r, &o); err != nil {
150+ return fmt.Errorf("next[%d]: %w", i, err)
151+ }
152+ n.Options = append(n.Options, o)
153+ }
154+ return nil
155+ }
156+ return fmt.Errorf("next must be \"stop\", an action name, an option or a list of options")
157+}
158+
159+// Option is run.rtw.ci.step.result#option.
160+type Option struct {
161+ Action string `json:"action"`
162+ Then string `json:"then,omitempty"`
163+ Label string `json:"label,omitempty"`
164+ Prompt string `json:"prompt,omitempty"`
165+ Inputs json.RawMessage `json:"inputs,omitempty"`
166+}
167+
168+type Attachment struct {
169+ Label string `json:"label,omitempty"`
170+ Path string `json:"path"`
171+ Type string `json:"type,omitempty"`
172+}
173+
174+// Gate states.
175+const (
176+ GateWaiting = "waiting"
177+ GateApproved = "approved"
178+ GateDismissed = "dismissed"
179+ GateExpired = "expired"
180+ GateSuperseded = "superseded"
181+)
182+
183+// GateRecord is run.rtw.ci.gate.
184+type GateRecord struct {
185+ Pipeline string `json:"pipeline,omitempty"`
186+ Run string `json:"run"`
187+ Workflow string `json:"workflow"`
188+ Seq int `json:"seq"`
189+ From string `json:"from,omitempty"`
190+ Prompt string `json:"prompt,omitempty"`
191+ Summary string `json:"summary,omitempty"`
192+ Options []Option `json:"options"`
193+ State string `json:"state"`
194+ Choice string `json:"choice,omitempty"`
195+ Actor string `json:"actor,omitempty"`
196+ ExpiresAt string `json:"expiresAt,omitempty"`
197+ CreatedAt string `json:"createdAt"`
198+ DecidedAt string `json:"decidedAt,omitempty"`
199+}
200+
201+// Transition reasons.
202+const (
203+ ReasonStart = "start"
204+ ReasonDefault = "default"
205+ ReasonNext = "next"
206+ ReasonChoice = "choice"
207+ ReasonManual = "manual"
208+ ReasonStop = "stop"
209+ ReasonDismiss = "dismiss"
210+ ReasonFailure = "failure"
211+ ReasonError = "error"
212+ ReasonLimit = "limit"
213+)
214+
215+// Transition is run.rtw.ci.transition.
216+type Transition struct {
217+ Pipeline string `json:"pipeline,omitempty"`
218+ Run string `json:"run"`
219+ Workflow string `json:"workflow"`
220+ Seq int `json:"seq"`
221+ From string `json:"from,omitempty"`
222+ To string `json:"to,omitempty"`
223+ Then string `json:"then,omitempty"`
224+ Reason string `json:"reason"`
225+ Detail string `json:"detail,omitempty"`
226+ CreatedAt string `json:"createdAt"`
227+}
228+
229+// Event kinds.
230+const (
231+ KindRun = "run"
232+ KindCheckout = "checkout"
233+ KindStart = "start"
234+ KindLog = "log"
235+ KindEnd = "end"
236+ KindResult = "result"
237+ KindTransition = "transition"
238+ KindGate = "gate"
239+)
240+
241+// RunEvent is run.rtw.ci.event, one line of events.ndjson.
242+type RunEvent struct {
243+ Seq int `json:"seq"`
244+ Time string `json:"time"`
245+ Run string `json:"run"`
246+ Kind string `json:"kind"`
247+ Action string `json:"action,omitempty"`
248+ Attempt int `json:"attempt,omitempty"`
249+ Stream string `json:"stream,omitempty"`
250+ Content string `json:"content,omitempty"`
251+ Command string `json:"command,omitempty"`
252+ ExitCode *int `json:"exitCode,omitempty"`
253+ Status string `json:"status,omitempty"`
254+ Repo string `json:"repo,omitempty"`
255+ Rev string `json:"rev,omitempty"`
256+ Path string `json:"path,omitempty"`
257+ Result *Result `json:"result,omitempty"`
258+ Transition *Transition `json:"transition,omitempty"`
259+ Gate *GateRecord `json:"gate,omitempty"`
260+}
261+
262+// strictUnmarshal decodes JSON rejecting unknown fields, so a typo in a
263+// result ("summray") is a contract error rather than silently ignored.
264+func strictUnmarshal(b []byte, v any) error {
265+ d := json.NewDecoder(bytes.NewReader(b))
266+ d.DisallowUnknownFields()
267+ if err := d.Decode(v); err != nil {
268+ return err
269+ }
270+ if d.More() {
271+ return fmt.Errorf("trailing data after JSON value")
272+ }
273+ return nil
274+}
275+
276+// StrictUnmarshal is strictUnmarshal for other packages.
277+func StrictUnmarshal(b []byte, v any) error { return strictUnmarshal(b, v) }
+219,
-0
1@@ -0,0 +1,219 @@
2+package lex
3+
4+import (
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "slices"
10+ "sort"
11+ "strings"
12+ "testing"
13+)
14+
15+// lexObject is the part of a lexicon object definition the test compares.
16+type lexObject struct {
17+ Required []string `json:"required"`
18+ Properties map[string]json.RawMessage `json:"properties"`
19+}
20+
21+// loadDef returns the object definition at ref ("<nsid>#<def>", where def may
22+// be "main", "main.input" or "main.output" for procedures).
23+func loadDef(t *testing.T, ref string) lexObject {
24+ t.Helper()
25+ nsid, def, _ := strings.Cut(ref, "#")
26+ var path string
27+ for _, dir := range []string{"tangled", "ci"} {
28+ p := filepath.Join("..", "..", "lexicons", dir, nsid+".json")
29+ if _, err := os.Stat(p); err == nil {
30+ path = p
31+ }
32+ }
33+ if path == "" {
34+ t.Fatalf("%s: no lexicon file", ref)
35+ }
36+ b, err := os.ReadFile(path)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ var doc struct {
41+ Defs map[string]struct {
42+ lexObject
43+ Record *lexObject `json:"record"`
44+ Input *struct {
45+ Schema lexObject `json:"schema"`
46+ } `json:"input"`
47+ Output *struct {
48+ Schema lexObject `json:"schema"`
49+ } `json:"output"`
50+ } `json:"defs"`
51+ }
52+ if err := json.Unmarshal(b, &doc); err != nil {
53+ t.Fatalf("%s: %v", path, err)
54+ }
55+ name, part, _ := strings.Cut(def, ".")
56+ d, ok := doc.Defs[name]
57+ if !ok {
58+ t.Fatalf("%s: no def %q", path, name)
59+ }
60+ switch {
61+ case part == "input":
62+ return d.Input.Schema
63+ case part == "output":
64+ return d.Output.Schema
65+ case d.Record != nil:
66+ return *d.Record
67+ }
68+ return d.lexObject
69+}
70+
71+// jsonFields returns a struct's JSON field names (without $type) and which of
72+// them are omitempty.
73+func jsonFields(typ reflect.Type) (names []string, omit map[string]bool) {
74+ omit = map[string]bool{}
75+ for f := range typ.Fields() {
76+ tag := f.Tag.Get("json")
77+ name, opts, _ := strings.Cut(tag, ",")
78+ if name == "" || name == "-" || name == "$type" {
79+ continue
80+ }
81+ names = append(names, name)
82+ omit[name] = strings.Contains(opts, "omitempty")
83+ }
84+ sort.Strings(names)
85+ return names, omit
86+}
87+
88+func TestTypesMatchLexicons(t *testing.T) {
89+ cases := []struct {
90+ ref string
91+ v any
92+ }{
93+ {"sh.tangled.git.refUpdate#main", GitRefUpdate{}},
94+ {"sh.tangled.git.refUpdate#meta", GitRefUpdateMeta{}},
95+ {"sh.tangled.git.refUpdate#langBreakdown", GitRefUpdateLangBreakdown{}},
96+ {"sh.tangled.git.refUpdate#individualLanguageSize", GitRefUpdateLanguageSize{}},
97+ {"sh.tangled.git.refUpdate#commitCountBreakdown", GitRefUpdateCommitCount{}},
98+ {"sh.tangled.git.refUpdate#individualEmailCommitCount", GitRefUpdateEmailCommitCount{}},
99+ {"sh.tangled.pipeline#main", Pipeline{}},
100+ {"sh.tangled.pipeline#triggerMetadata", PipelineTriggerMetadata{}},
101+ {"sh.tangled.pipeline#triggerRepo", PipelineTriggerRepo{}},
102+ {"sh.tangled.pipeline#pushTriggerData", PipelinePushTriggerData{}},
103+ {"sh.tangled.pipeline#pullRequestTriggerData", PipelinePullRequestTrigger{}},
104+ {"sh.tangled.pipeline#manualTriggerData", PipelineManualTriggerData{}},
105+ {"sh.tangled.pipeline#scheduleTriggerData", PipelineScheduleTrigger{}},
106+ {"sh.tangled.pipeline#workflow", PipelineWorkflow{}},
107+ {"sh.tangled.pipeline#cloneOpts", PipelineCloneOpts{}},
108+ {"sh.tangled.pipeline#pair", PipelinePair{}},
109+ {"sh.tangled.pipeline.status#main", PipelineStatus{}},
110+ {"sh.tangled.ci.triggerPipeline#main.input", TriggerPipelineInput{}},
111+ {"sh.tangled.ci.triggerPipeline#main.output", TriggerPipelineOutput{}},
112+ {"sh.tangled.ci.trigger#manual", ManualTrigger{}},
113+ {"run.rtw.ci.workflow#main", Workflow{}},
114+ {"run.rtw.ci.workflow#trigger", Trigger{}},
115+ {"run.rtw.ci.workflow#repo", Repo{}},
116+ {"run.rtw.ci.workflow#action", Action{}},
117+ {"run.rtw.ci.workflow#gate", Gate{}},
118+ {"run.rtw.ci.workflow#concurrency", Concurrency{}},
119+ {"run.rtw.ci.step.result#main", Result{}},
120+ {"run.rtw.ci.step.result#option", Option{}},
121+ {"run.rtw.ci.step.result#attachment", Attachment{}},
122+ {"run.rtw.ci.gate#main", GateRecord{}},
123+ {"run.rtw.ci.transition#main", Transition{}},
124+ {"run.rtw.ci.event#main", RunEvent{}},
125+ }
126+ for _, c := range cases {
127+ t.Run(c.ref, func(t *testing.T) {
128+ def := loadDef(t, c.ref)
129+ want := make([]string, 0, len(def.Properties))
130+ for k := range def.Properties {
131+ want = append(want, k)
132+ }
133+ sort.Strings(want)
134+ got, omit := jsonFields(reflect.TypeOf(c.v))
135+ if !slices.Equal(got, want) {
136+ t.Errorf("fields differ\n go: %v\n lexicon: %v", got, want)
137+ }
138+ for _, r := range def.Required {
139+ if omit[r] {
140+ t.Errorf("required property %q is omitempty in Go", r)
141+ }
142+ }
143+ })
144+ }
145+}
146+
147+func TestNextForms(t *testing.T) {
148+ cases := []struct {
149+ in, out string
150+ stop bool
151+ n int
152+ }{
153+ {`"stop"`, `"stop"`, true, 0},
154+ {`"rollback"`, `[{"action":"rollback"}]`, false, 1},
155+ {`{"action":"rollback","then":"stop"}`, `[{"action":"rollback","then":"stop"}]`, false, 1},
156+ {`[{"action":"a"},{"action":"b","label":"B"}]`, `[{"action":"a"},{"action":"b","label":"B"}]`, false, 2},
157+ }
158+ for _, c := range cases {
159+ var n Next
160+ if err := json.Unmarshal([]byte(c.in), &n); err != nil {
161+ t.Fatalf("%s: %v", c.in, err)
162+ }
163+ if n.Stop != c.stop || len(n.Options) != c.n {
164+ t.Errorf("%s: got %+v", c.in, n)
165+ }
166+ b, _ := json.Marshal(n)
167+ if string(b) != c.out {
168+ t.Errorf("%s: marshals to %s, want %s", c.in, b, c.out)
169+ }
170+ }
171+ for _, bad := range []string{`""`, `[]`, `5`, `{"action":"a","oops":1}`, `[{"actoin":"a"}]`} {
172+ var n Next
173+ if err := json.Unmarshal([]byte(bad), &n); err == nil {
174+ t.Errorf("%s: accepted", bad)
175+ }
176+ }
177+}
178+
179+// The example result from DESIGN.md §7 round-trips.
180+func TestDesignResultExample(t *testing.T) {
181+ in := `{
182+ "version": 1,
183+ "status": "failure",
184+ "summary": "mx99.eu.sovrn.at /healthz 503 for 4m (stalwart: connection refused)",
185+ "next": [
186+ { "action": "rollback", "prompt": "mx99 degraded after deploy. Roll back sovrn?", "then": "stop" },
187+ { "action": "health-check", "label": "Check again" }
188+ ],
189+ "gated": true,
190+ "attachments": [ { "label": "health probes", "path": "health.ndjson", "type": "ndjson" } ],
191+ "outputs": { "hosts": ["mx99.eu.sovrn.at"] }
192+}`
193+ var r Result
194+ if err := StrictUnmarshal([]byte(in), &r); err != nil {
195+ t.Fatal(err)
196+ }
197+ if r.Status != Failure || !r.Gated || len(r.Next.Options) != 2 || r.Next.Options[0].Then != ThenStop {
198+ t.Fatalf("decoded %+v", r)
199+ }
200+ b, err := json.Marshal(r)
201+ if err != nil {
202+ t.Fatal(err)
203+ }
204+ var again Result
205+ if err := StrictUnmarshal(b, &again); err != nil {
206+ t.Fatalf("re-decode %s: %v", b, err)
207+ }
208+ if !reflect.DeepEqual(normalise(t, r), normalise(t, again)) {
209+ t.Errorf("round trip changed the result:\n%s", b)
210+ }
211+}
212+
213+func normalise(t *testing.T, r Result) map[string]any {
214+ b, _ := json.Marshal(r)
215+ var m map[string]any
216+ if err := json.Unmarshal(b, &m); err != nil {
217+ t.Fatal(err)
218+ }
219+ return m
220+}
+192,
-0
1@@ -0,0 +1,192 @@
2+// Package lex holds Go types for the lexicons this project reads and emits:
3+// the vendored Tangled ones (lexicons/tangled) and our own run.rtw.ci.*
4+// (lexicons/ci). Types are hand-written; lex_test.go checks each against its
5+// lexicon so the two cannot drift.
6+package lex
7+
8+import "encoding/json"
9+
10+const (
11+ GitRefUpdateNSID = "sh.tangled.git.refUpdate"
12+ PipelineNSID = "sh.tangled.pipeline"
13+ PipelineStatusNSID = "sh.tangled.pipeline.status"
14+)
15+
16+// Event is the envelope of a Tangled /events stream (upstream
17+// eventstream.Event). Created is a monotonic local clock value and is the
18+// cursor: a subscriber resumes with ?cursor=<last Created>.
19+type Event struct {
20+ Rkey string `json:"rkey"`
21+ Nsid string `json:"nsid"`
22+ Event json.RawMessage `json:"event"`
23+ Created int64 `json:"created"`
24+}
25+
26+// GitRefUpdate is sh.tangled.git.refUpdate, emitted by a knot (here: the
27+// bridge) for every pushed ref.
28+type GitRefUpdate struct {
29+ LexiconTypeID string `json:"$type"`
30+ Ref string `json:"ref"`
31+ CommitterDid string `json:"committerDid"`
32+ OwnerDid *string `json:"ownerDid,omitempty"`
33+ Repo string `json:"repo"`
34+ OldSha string `json:"oldSha"`
35+ NewSha string `json:"newSha"`
36+ ChangedFiles []string `json:"changedFiles,omitempty"`
37+ PushOptions []string `json:"pushOptions,omitempty"`
38+ Meta *GitRefUpdateMeta `json:"meta"`
39+}
40+
41+type GitRefUpdateMeta struct {
42+ IsDefaultRef bool `json:"isDefaultRef"`
43+ LangBreakdown *GitRefUpdateLangBreakdown `json:"langBreakdown,omitempty"`
44+ CommitCount *GitRefUpdateCommitCount `json:"commitCount"`
45+}
46+
47+type GitRefUpdateLangBreakdown struct {
48+ Inputs []GitRefUpdateLanguageSize `json:"inputs,omitempty"`
49+}
50+
51+type GitRefUpdateLanguageSize struct {
52+ Lang string `json:"lang"`
53+ Size int64 `json:"size"`
54+}
55+
56+type GitRefUpdateCommitCount struct {
57+ ByEmail []GitRefUpdateEmailCommitCount `json:"byEmail,omitempty"`
58+}
59+
60+type GitRefUpdateEmailCommitCount struct {
61+ Email string `json:"email"`
62+ Count int64 `json:"count"`
63+}
64+
65+// Pipeline is sh.tangled.pipeline. Upstream marks the record deprecated in
66+// favour of sh.tangled.ci.pipeline, but the spindle still emits it on
67+// /events and the appview still consumes it.
68+type Pipeline struct {
69+ LexiconTypeID string `json:"$type"`
70+ TriggerMetadata *PipelineTriggerMetadata `json:"triggerMetadata"`
71+ Workflows []*PipelineWorkflow `json:"workflows"`
72+}
73+
74+type PipelineTriggerMetadata struct {
75+ Kind string `json:"kind"`
76+ Repo *PipelineTriggerRepo `json:"repo"`
77+ Push *PipelinePushTriggerData `json:"push,omitempty"`
78+ PullRequest *PipelinePullRequestTrigger `json:"pullRequest,omitempty"`
79+ Manual *PipelineManualTriggerData `json:"manual,omitempty"`
80+ Schedule *PipelineScheduleTrigger `json:"schedule,omitempty"`
81+ SourceRepo *string `json:"sourceRepo,omitempty"`
82+}
83+
84+type PipelineTriggerRepo struct {
85+ Knot string `json:"knot"`
86+ Did string `json:"did"`
87+ RepoDid *string `json:"repoDid,omitempty"`
88+ Repo *string `json:"repo,omitempty"`
89+ DefaultBranch string `json:"defaultBranch"`
90+}
91+
92+type PipelinePushTriggerData struct {
93+ Ref string `json:"ref"`
94+ NewSha string `json:"newSha"`
95+ OldSha string `json:"oldSha"`
96+}
97+
98+type PipelinePullRequestTrigger struct {
99+ Action *string `json:"action,omitempty"`
100+ SourceBranch string `json:"sourceBranch"`
101+ TargetBranch string `json:"targetBranch"`
102+ SourceSha string `json:"sourceSha"`
103+ Pull *string `json:"pull,omitempty"`
104+}
105+
106+type PipelineManualTriggerData struct {
107+ Sha string `json:"sha"`
108+ Ref *string `json:"ref,omitempty"`
109+ Inputs []*PipelinePair `json:"inputs,omitempty"`
110+}
111+
112+type PipelineScheduleTrigger struct {
113+ Sha string `json:"sha"`
114+ Ref string `json:"ref"`
115+ ScheduledAt string `json:"scheduledAt"`
116+}
117+
118+type PipelineWorkflow struct {
119+ Name string `json:"name"`
120+ Engine string `json:"engine"`
121+ RunsOn []string `json:"runsOn,omitempty"`
122+ Clone *PipelineCloneOpts `json:"clone"`
123+ Raw string `json:"raw"`
124+}
125+
126+type PipelineCloneOpts struct {
127+ Skip bool `json:"skip"`
128+ Depth int64 `json:"depth"`
129+ Submodules bool `json:"submodules"`
130+ Tags bool `json:"tags"`
131+}
132+
133+type PipelinePair struct {
134+ Key string `json:"key"`
135+ Value string `json:"value"`
136+}
137+
138+// Workflow statuses shared by sh.tangled.pipeline.status and
139+// sh.tangled.ci.pipeline#workflow.
140+const (
141+ StatusPending = "pending"
142+ StatusRunning = "running"
143+ StatusFailed = "failed"
144+ StatusTimeout = "timeout"
145+ StatusCancelled = "cancelled"
146+ StatusSuccess = "success"
147+)
148+
149+// PipelineStatus is sh.tangled.pipeline.status: one workflow's status change.
150+type PipelineStatus struct {
151+ LexiconTypeID string `json:"$type"`
152+ Pipeline string `json:"pipeline"`
153+ Workflow string `json:"workflow"`
154+ Status string `json:"status"`
155+ CreatedAt string `json:"createdAt"`
156+ Error *string `json:"error,omitempty"`
157+ ExitCode *int64 `json:"exitCode,omitempty"`
158+}
159+
160+// LogLine is one message of a spindle's legacy /logs/{knot}/{rkey}/{name}
161+// websocket, as upstream's spindle/models.LogLine (not a lexicon). Data lines
162+// carry output; control lines mark a step's start and end.
163+type LogLine struct {
164+ Kind string `json:"kind"` // data | control
165+ Content string `json:"content"`
166+ Time string `json:"time"` // RFC 3339
167+ StepId int `json:"step_id"`
168+ Stream string `json:"stream,omitempty"` // data: stdout | stderr
169+ StepStatus string `json:"step_status,omitempty"` // control: start | end
170+ StepKind string `json:"step_kind,omitempty"` // control: system | user
171+ StepCommand string `json:"step_command,omitempty"` // control
172+}
173+
174+// TriggerPipelineInput is the input of sh.tangled.ci.triggerPipeline, with
175+// the manual trigger (the only kind accepted here) inlined.
176+type TriggerPipelineInput struct {
177+ Repo string `json:"repo"`
178+ Trigger *ManualTrigger `json:"trigger"`
179+ Workflows []string `json:"workflows,omitempty"`
180+}
181+
182+// ManualTrigger is sh.tangled.ci.trigger#manual.
183+type ManualTrigger struct {
184+ LexiconTypeID string `json:"$type"`
185+ Sha string `json:"sha"`
186+ Ref *string `json:"ref,omitempty"`
187+ SourceRepo *string `json:"sourceRepo,omitempty"`
188+ Inputs []*PipelinePair `json:"inputs,omitempty"`
189+}
190+
191+type TriggerPipelineOutput struct {
192+ Pipeline string `json:"pipeline"`
193+}
+30,
-0
1@@ -0,0 +1,30 @@
2+{
3+ "lexicon": 1,
4+ "id": "run.rtw.ci.event",
5+ "description": "One line of a run's events.ndjson: the single observable format read by the terminal renderer, ci view, the runner's streams and the appview.",
6+ "defs": {
7+ "main": {
8+ "type": "object",
9+ "required": ["seq", "time", "kind", "run"],
10+ "properties": {
11+ "seq": { "type": "integer", "description": "Line number in the file, from 1." },
12+ "time": { "type": "string", "format": "datetime" },
13+ "run": { "type": "string" },
14+ "kind": { "type": "string", "knownValues": ["run", "checkout", "start", "log", "end", "result", "transition", "gate"] },
15+ "action": { "type": "string" },
16+ "attempt": { "type": "integer", "description": "Execution number of the action within the run (1, 2, ... for repeats); also the log step id." },
17+ "stream": { "type": "string", "knownValues": ["stdout", "stderr"] },
18+ "content": { "type": "string", "description": "A log line (kind log), a message (run, checkout)." },
19+ "command": { "type": "string" },
20+ "exitCode": { "type": "integer" },
21+ "status": { "type": "string", "description": "kind run: pending|running|paused|success|failure|cancelled; kind end: the action's status." },
22+ "repo": { "type": "string", "description": "kind checkout: repo name." },
23+ "rev": { "type": "string", "description": "kind checkout: checked-out commit." },
24+ "path": { "type": "string", "description": "kind checkout: checkout path." },
25+ "result": { "type": "ref", "ref": "run.rtw.ci.step.result" },
26+ "transition": { "type": "ref", "ref": "run.rtw.ci.transition" },
27+ "gate": { "type": "ref", "ref": "run.rtw.ci.gate" }
28+ }
29+ }
30+ }
31+}
+27,
-0
1@@ -0,0 +1,27 @@
2+{
3+ "lexicon": 1,
4+ "id": "run.rtw.ci.gate",
5+ "description": "A pending or decided human decision in a run. Emitted on the runner's /events; tid record keys.",
6+ "defs": {
7+ "main": {
8+ "type": "object",
9+ "required": ["run", "workflow", "seq", "options", "state", "createdAt"],
10+ "properties": {
11+ "pipeline": { "type": "string", "format": "at-uri", "description": "Absent for local runs." },
12+ "run": { "type": "string", "description": "Run id." },
13+ "workflow": { "type": "string" },
14+ "seq": { "type": "integer", "description": "Transition number the gate guards." },
15+ "from": { "type": "string", "description": "The action whose result raised the gate; absent for a declared gate on the next step." },
16+ "prompt": { "type": "string" },
17+ "summary": { "type": "string" },
18+ "options": { "type": "array", "items": { "type": "ref", "ref": "run.rtw.ci.step.result#option" } },
19+ "state": { "type": "string", "knownValues": ["waiting", "approved", "dismissed", "expired", "superseded"] },
20+ "choice": { "type": "string", "description": "The chosen action, when approved." },
21+ "actor": { "type": "string", "description": "Who decided: a DID, or local:<user> for terminal decisions." },
22+ "expiresAt": { "type": "string", "format": "datetime" },
23+ "createdAt": { "type": "string", "format": "datetime" },
24+ "decidedAt": { "type": "string", "format": "datetime" }
25+ }
26+ }
27+ }
28+}
+43,
-0
1@@ -0,0 +1,43 @@
2+{
3+ "lexicon": 1,
4+ "id": "run.rtw.ci.step.result",
5+ "description": "The result an action writes to $CI_RESULT. Without a result file, exit 0 means {status: success} and anything else means {status: failure, next: stop}.",
6+ "defs": {
7+ "main": {
8+ "type": "object",
9+ "required": ["version", "status"],
10+ "properties": {
11+ "version": { "type": "integer", "minimum": 1, "maximum": 1 },
12+ "status": { "type": "string", "knownValues": ["success", "warning", "failure"] },
13+ "summary": { "type": "string", "description": "Markdown for the run page, gates and notifications.", "maxLength": 65536 },
14+ "next": {
15+ "type": "unknown",
16+ "description": "Omitted: default routing. \"stop\": end the run. An action name, one #option, or an array of #option."
17+ },
18+ "gated": { "type": "boolean", "description": "Ask a human before taking next. Can add a gate, never remove a declared one." },
19+ "attachments": { "type": "array", "items": { "type": "ref", "ref": "#attachment" } },
20+ "outputs": { "type": "unknown", "description": "Object merged into $CI_STATE/outputs.json." }
21+ }
22+ },
23+ "option": {
24+ "type": "object",
25+ "required": ["action"],
26+ "properties": {
27+ "action": { "type": "string", "description": "A declared action." },
28+ "then": { "type": "string", "knownValues": ["continue", "stop"], "description": "After the action succeeds. Default: continue for happy-path steps, stop otherwise." },
29+ "label": { "type": "string" },
30+ "prompt": { "type": "string" },
31+ "inputs": { "type": "unknown", "description": "Object passed to the action as $CI_INPUTS." }
32+ }
33+ },
34+ "attachment": {
35+ "type": "object",
36+ "required": ["path"],
37+ "properties": {
38+ "label": { "type": "string" },
39+ "path": { "type": "string", "description": "Relative to $CI_ARTIFACTS." },
40+ "type": { "type": "string", "knownValues": ["text", "markdown", "ndjson", "json"] }
41+ }
42+ }
43+ }
44+}
+23,
-0
1@@ -0,0 +1,23 @@
2+{
3+ "lexicon": 1,
4+ "id": "run.rtw.ci.transition",
5+ "description": "One routing decision of the engine, for the run timeline.",
6+ "defs": {
7+ "main": {
8+ "type": "object",
9+ "required": ["run", "workflow", "seq", "reason", "createdAt"],
10+ "properties": {
11+ "pipeline": { "type": "string", "format": "at-uri" },
12+ "run": { "type": "string" },
13+ "workflow": { "type": "string" },
14+ "seq": { "type": "integer" },
15+ "from": { "type": "string", "description": "Action that just finished; absent at the start of a run." },
16+ "to": { "type": "string", "description": "Action that runs next; absent when the run ends." },
17+ "then": { "type": "string", "knownValues": ["continue", "stop"] },
18+ "reason": { "type": "string", "knownValues": ["start", "default", "next", "choice", "manual", "stop", "dismiss", "failure", "error", "limit"] },
19+ "detail": { "type": "string" },
20+ "createdAt": { "type": "string", "format": "datetime" }
21+ }
22+ }
23+ }
24+}
+102,
-0
1@@ -0,0 +1,102 @@
2+{
3+ "lexicon": 1,
4+ "id": "run.rtw.ci.workflow",
5+ "description": "A workflow manifest (.tangled/workflows/<name>.yml, engine devenv). Pure data: the happy path (steps) and the closed set of actions results may route to.",
6+ "defs": {
7+ "main": {
8+ "type": "object",
9+ "required": ["when", "engine", "steps", "actions"],
10+ "properties": {
11+ "when": {
12+ "type": "array",
13+ "description": "Trigger conditions, as upstream's workflow files. Matched by the runner on a ref update or manual trigger.",
14+ "items": { "type": "ref", "ref": "#trigger" }
15+ },
16+ "engine": {
17+ "type": "string",
18+ "description": "Must be devenv. Upstream spindles ignore workflows for engines they don't run.",
19+ "knownValues": ["devenv"]
20+ },
21+ "environment": {
22+ "type": "unknown",
23+ "description": "Map of variable name to string value, set for every action. Not for secrets."
24+ },
25+ "repos": {
26+ "type": "array",
27+ "description": "Extra repositories checked out next to the main one for each run.",
28+ "items": { "type": "ref", "ref": "#repo" }
29+ },
30+ "steps": {
31+ "type": "array",
32+ "description": "The happy path: action names in order.",
33+ "minLength": 1,
34+ "items": { "type": "string" }
35+ },
36+ "actions": {
37+ "type": "unknown",
38+ "description": "Map of action name to #action. Every step must name an action; results may route only to actions declared here."
39+ }
40+ }
41+ },
42+ "trigger": {
43+ "type": "object",
44+ "required": ["event"],
45+ "properties": {
46+ "event": {
47+ "type": "array",
48+ "description": "In YAML a single string is accepted too.",
49+ "items": { "type": "string", "knownValues": ["push", "manual", "pull_request", "schedule"] }
50+ },
51+ "branch": {
52+ "type": "array",
53+ "description": "Branch globs (* and **). In YAML a single string is accepted too.",
54+ "items": { "type": "string" }
55+ },
56+ "tag": {
57+ "type": "array",
58+ "description": "Tag globs (* and **), push events only. In YAML a single string is accepted too.",
59+ "items": { "type": "string" }
60+ }
61+ }
62+ },
63+ "repo": {
64+ "type": "object",
65+ "required": ["repo", "path"],
66+ "properties": {
67+ "repo": { "type": "string", "description": "Repository name on the forge (resolved to a clone URL by configuration)." },
68+ "path": { "type": "string", "description": "Checkout path relative to the main checkout, e.g. ../servers." },
69+ "rev": { "type": "string", "description": "Revision to check out. Default: the forge's default branch at the time the run starts." },
70+ "env": { "type": "string", "description": "Environment variable that receives the checkout's absolute path, e.g. SECRETS_DIR." }
71+ }
72+ },
73+ "action": {
74+ "type": "object",
75+ "required": ["command"],
76+ "properties": {
77+ "command": { "type": "string", "description": "Run as `devenv shell -- bash -euo pipefail -c <command>`." },
78+ "dir": { "type": "string", "description": "Working directory relative to the main checkout. Default: the main checkout." },
79+ "label": { "type": "string", "description": "Button and timeline label. Default: the action name." },
80+ "gate": { "type": "ref", "ref": "#gate", "description": "Always ask a human before running. In YAML `gate: true` is accepted too." },
81+ "concurrency": { "type": "ref", "ref": "#concurrency", "description": "In YAML a plain group name is accepted too." },
82+ "manual": { "type": "boolean", "description": "Offer as a button on the run page at any time." },
83+ "style": { "type": "string", "knownValues": ["normal", "danger"] },
84+ "audited": { "type": "boolean", "description": "Local runs that execute this action report to the appview." }
85+ }
86+ },
87+ "gate": {
88+ "type": "object",
89+ "properties": {
90+ "prompt": { "type": "string" },
91+ "timeout": { "type": "string", "description": "Go duration, e.g. 24h. Default 24h." }
92+ }
93+ },
94+ "concurrency": {
95+ "type": "object",
96+ "required": ["group"],
97+ "properties": {
98+ "group": { "type": "string" },
99+ "supersede": { "type": "boolean", "description": "A newer run reaching this group's gate supersedes older runs waiting at it." }
100+ }
101+ }
102+ }
103+}
+10,
-0
1@@ -0,0 +1,10 @@
2+# Vendored Tangled lexicons
3+
4+Copied unchanged from tangled core (`lexicons/`) at commit `e0ef7981d`
5+(2026-10-11), MIT License, Copyright (c) 2025 Anirudh Oppiliappan, Akshay
6+Oppiliappan and contributors. Files are renamed to their lexicon id.
7+
8+Only the records this project reads or emits are vendored. `internal/lex`
9+holds hand-written Go types for them; `internal/lex/lex_test.go` checks the
10+types against these files. To update: copy the newer files over these, run
11+`go test ./internal/lex`, and fix the types the test reports.
1@@ -0,0 +1,94 @@
2+{
3+ "lexicon": 1,
4+ "id": "sh.tangled.ci.pipeline",
5+ "defs": {
6+ "main": {
7+ "type": "object",
8+ "description": "A CI pipeline. Record-like, but owned by the spindle rather than a PDS.",
9+ "required": ["id", "trigger", "commit", "workflows"],
10+ "properties": {
11+ "id": {
12+ "type": "string",
13+ "description": "Spindle-local pipeline id"
14+ },
15+ "repo": {
16+ "type": "string",
17+ "format": "did",
18+ "description": "Repository DID"
19+ },
20+ "trigger": {
21+ "type": "union",
22+ "refs": [
23+ "sh.tangled.ci.trigger#push",
24+ "sh.tangled.ci.trigger#pullRequest",
25+ "sh.tangled.ci.trigger#manual",
26+ "sh.tangled.ci.trigger#schedule"
27+ ],
28+ "description": "Trigger event metadata"
29+ },
30+ "commit": {
31+ "type": "string",
32+ "description": "Commit Id this pipeline is running on"
33+ },
34+ "sourceRepo": {
35+ "type": "string",
36+ "format": "did",
37+ "description": "Repository DID that the commit was checked out from, if different from repo (e.g. a fork for a fork-based pull request)"
38+ },
39+ "createdAt": {
40+ "type": "string",
41+ "format": "datetime"
42+ },
43+ "workflows": {
44+ "type": "array",
45+ "description": "Triggered workflows",
46+ "minLength": 1,
47+ "maxLength": 50,
48+ "items": {
49+ "type": "ref",
50+ "ref": "#workflow"
51+ }
52+ }
53+ }
54+ },
55+ "workflow": {
56+ "type": "object",
57+ "required": ["id", "name", "status"],
58+ "properties": {
59+ "id": {
60+ "type": "string",
61+ "description": "Spindle-local workflow id. Unique per pipeline, usually same as name."
62+ },
63+ "name": {
64+ "type": "string",
65+ "description": "Name of the workflow",
66+ "minGraphemes": 1,
67+ "maxGraphemes": 40
68+ },
69+ "status": {
70+ "type": "string",
71+ "description": "Workflow status",
72+ "enum": [
73+ "pending",
74+ "running",
75+ "failed",
76+ "timeout",
77+ "cancelled",
78+ "success"
79+ ]
80+ },
81+ "startedAt": {
82+ "type": "string",
83+ "format": "datetime"
84+ },
85+ "finishedAt": {
86+ "type": "string",
87+ "format": "datetime"
88+ },
89+ "error": {
90+ "type": "string"
91+ }
92+ }
93+ }
94+ }
95+}
1@@ -0,0 +1,77 @@
2+{
3+ "lexicon": 1,
4+ "id": "sh.tangled.ci.subscribePipelineLogs",
5+ "defs": {
6+ "main": {
7+ "type": "subscription",
8+ "description": "Pipeline logs stream",
9+ "parameters": {
10+ "type": "params",
11+ "required": ["pipeline"],
12+ "properties": {
13+ "pipeline": {
14+ "type": "string",
15+ "format": "tid",
16+ "description": "Pipeline ID"
17+ },
18+ "workflows": {
19+ "type": "array",
20+ "items": {
21+ "type": "string",
22+ "description": "Workflow name"
23+ },
24+ "description": "filter logs by specific workflows"
25+ }
26+ }
27+ },
28+ "message": {
29+ "schema": {
30+ "type": "union",
31+ "refs": ["#control", "#data"]
32+ }
33+ },
34+ "errors": [
35+ {
36+ "name": "WorkflowNotFound",
37+ "description": "Workflow not found"
38+ },
39+ {
40+ "name": "InvalidRequest",
41+ "description": "Invalid request parameters"
42+ }
43+ ]
44+ },
45+ "control": {
46+ "type": "object",
47+ "required": ["time", "workflow", "step", "content"],
48+ "properties": {
49+ "time": { "type": "string", "format": "datetime" },
50+ "workflow": { "type": "string", "description": "workflow name" },
51+ "step": { "type": "integer", "description": "Step ID" },
52+ "content": { "type": "string" },
53+ "command": { "type": "string", "description": "Step command" },
54+ "status": {
55+ "type": "string",
56+ "enum": ["start", "end"],
57+ "description": "Step status"
58+ },
59+ "kind": {
60+ "type": "string",
61+ "enum": ["system", "user"],
62+ "description": "Step kind"
63+ }
64+ }
65+ },
66+ "data": {
67+ "type": "object",
68+ "required": ["time", "workflow", "step", "content", "stream"],
69+ "properties": {
70+ "time": { "type": "string", "format": "datetime" },
71+ "workflow": { "type": "string", "description": "workflow name" },
72+ "step": { "type": "integer", "description": "Step ID" },
73+ "content": { "type": "string" },
74+ "stream": { "type": "string", "enum": [ "stdout", "stderr" ] }
75+ }
76+ }
77+ }
78+}
+119,
-0
1@@ -0,0 +1,119 @@
2+{
3+ "lexicon": 1,
4+ "id": "sh.tangled.ci.trigger",
5+ "defs": {
6+ "push": {
7+ "type": "object",
8+ "required": ["ref", "newSha", "oldSha"],
9+ "properties": {
10+ "ref": {
11+ "type": "string"
12+ },
13+ "newSha": {
14+ "type": "string",
15+ "minLength": 40,
16+ "maxLength": 40
17+ },
18+ "oldSha": {
19+ "type": "string",
20+ "minLength": 40,
21+ "maxLength": 40
22+ }
23+ }
24+ },
25+ "pullRequest": {
26+ "type": "object",
27+ "required": ["targetBranch", "sourceSha"],
28+ "description": "TODO: reference PR record with strongRef instead of embedding raw values",
29+ "properties": {
30+ "action": {
31+ "type": "string",
32+ "description": "the pull request lifecycle action that produced this trigger",
33+ "enum": ["opened", "reopened", "closed", "merged", "synchronize"]
34+ },
35+ "sourceRepo": {
36+ "type": "string",
37+ "format": "did",
38+ "description": "Repository DID to check out code and workflow definitions from, if different from the target repo."
39+ },
40+ "sourceBranch": {
41+ "type": "string"
42+ },
43+ "targetBranch": {
44+ "type": "string"
45+ },
46+ "sourceSha": {
47+ "type": "string",
48+ "minLength": 40,
49+ "maxLength": 40
50+ },
51+ "pull": {
52+ "type": "string",
53+ "format": "at-uri",
54+ "description": "AT-URI of the sh.tangled.repo.pull record this run belongs to"
55+ }
56+ }
57+ },
58+ "manual": {
59+ "type": "object",
60+ "required": ["sha"],
61+ "properties": {
62+ "sha": {
63+ "type": "string",
64+ "description": "commit SHA the manual run targets",
65+ "minLength": 40,
66+ "maxLength": 40
67+ },
68+ "ref": {
69+ "type": "string",
70+ "description": "optional ref the SHA was resolved from, for display and TANGLED_REF"
71+ },
72+ "sourceRepo": {
73+ "type": "string",
74+ "format": "did",
75+ "description": "Repository DID to check out code and workflow definitions from, if different from the target repo."
76+ },
77+ "inputs": {
78+ "type": "array",
79+ "items": {
80+ "type": "ref",
81+ "ref": "#pair"
82+ }
83+ }
84+ }
85+ },
86+ "schedule": {
87+ "type": "object",
88+ "required": ["sha", "ref", "scheduledAt"],
89+ "properties": {
90+ "sha": {
91+ "type": "string",
92+ "description": "commit SHA the scheduled run targets",
93+ "minLength": 40,
94+ "maxLength": 40
95+ },
96+ "ref": {
97+ "type": "string",
98+ "description": "default branch ref resolved for the scheduled run"
99+ },
100+ "scheduledAt": {
101+ "type": "string",
102+ "format": "datetime",
103+ "description": "UTC minute selected by the cron schedule"
104+ }
105+ }
106+ },
107+ "pair": {
108+ "type": "object",
109+ "required": ["key", "value"],
110+ "properties": {
111+ "key": {
112+ "type": "string"
113+ },
114+ "value": {
115+ "type": "string"
116+ }
117+ }
118+ }
119+ }
120+}
1@@ -0,0 +1,60 @@
2+{
3+ "lexicon": 1,
4+ "id": "sh.tangled.ci.triggerPipeline",
5+ "defs": {
6+ "main": {
7+ "type": "procedure",
8+ "description": "Trigger a pipeline at an explicit commit. Runs the named workflows, or every workflow defined in the repo when none are named.",
9+ "input": {
10+ "encoding": "application/json",
11+ "schema": {
12+ "type": "object",
13+ "required": ["repo", "trigger"],
14+ "properties": {
15+ "repo": {
16+ "type": "string",
17+ "format": "did",
18+ "description": "Target repository DID. Auth is checked against this repo."
19+ },
20+ "trigger": {
21+ "type": "union",
22+ "refs": [
23+ "sh.tangled.ci.trigger#manual",
24+ "sh.tangled.ci.trigger#pullRequest"
25+ ],
26+ "description": "Trigger metadata for this dispatch."
27+ },
28+ "workflows": {
29+ "type": "array",
30+ "items": {
31+ "type": "string",
32+ "description": "workflow name"
33+ },
34+ "description": "Workflow names to run. When not provided, every dispatchable workflow is run."
35+ }
36+ }
37+ }
38+ },
39+ "output": {
40+ "encoding": "application/json",
41+ "schema": {
42+ "type": "object",
43+ "required": ["pipeline"],
44+ "properties": {
45+ "pipeline": {
46+ "type": "string",
47+ "format": "at-uri",
48+ "description": "AT-URI of the created pipeline"
49+ }
50+ }
51+ }
52+ },
53+ "errors": [
54+ {
55+ "name": "InvalidRequest",
56+ "description": "Invalid request parameters"
57+ }
58+ ]
59+ }
60+ }
61+}
1@@ -0,0 +1,148 @@
2+{
3+ "lexicon": 1,
4+ "id": "sh.tangled.git.refUpdate",
5+ "defs": {
6+ "main": {
7+ "type": "record",
8+ "description": "An event record representing git-push operation to git repository, emitted by knots.",
9+ "key": "tid",
10+ "record": {
11+ "type": "object",
12+ "required": [
13+ "ref",
14+ "committerDid",
15+ "repo",
16+ "oldSha",
17+ "newSha",
18+ "meta"
19+ ],
20+ "properties": {
21+ "ref": {
22+ "type": "string",
23+ "description": "Ref being updated",
24+ "maxGraphemes": 256,
25+ "maxLength": 2560
26+ },
27+ "committerDid": {
28+ "type": "string",
29+ "description": "did of the user that pushed this ref",
30+ "format": "did"
31+ },
32+ "ownerDid": {
33+ "type": "string",
34+ "description": "did of the owner of the repo",
35+ "format": "did"
36+ },
37+ "repo": {
38+ "type": "string",
39+ "description": "DID of the repo itself",
40+ "format": "did"
41+ },
42+ "oldSha": {
43+ "type": "string",
44+ "description": "old SHA of this ref",
45+ "minLength": 40,
46+ "maxLength": 40
47+ },
48+ "newSha": {
49+ "type": "string",
50+ "description": "new SHA of this ref",
51+ "minLength": 40,
52+ "maxLength": 40
53+ },
54+ "changedFiles": {
55+ "type": "array",
56+ "description": "files changed between commits",
57+ "items": { "type": "string" }
58+ },
59+ "pushOptions": {
60+ "type": "array",
61+ "description": "push options passed on git-push",
62+ "maxLength": 50,
63+ "items": {
64+ "type": "string",
65+ "maxLength": 1024,
66+ "knownValues": [
67+ "ci-skip",
68+ "ci-verbose",
69+ "skip-ci",
70+ "verbose-ci"
71+ ]
72+ }
73+ },
74+ "meta": {
75+ "type": "ref",
76+ "ref": "#meta"
77+ }
78+ }
79+ }
80+ },
81+ "meta": {
82+ "type": "object",
83+ "required": ["isDefaultRef", "commitCount"],
84+ "properties": {
85+ "isDefaultRef": {
86+ "type": "boolean",
87+ "default": false
88+ },
89+ "langBreakdown": {
90+ "type": "ref",
91+ "ref": "#langBreakdown"
92+ },
93+ "commitCount": {
94+ "type": "ref",
95+ "ref": "#commitCountBreakdown"
96+ }
97+ }
98+ },
99+ "langBreakdown": {
100+ "type": "object",
101+ "properties": {
102+ "inputs": {
103+ "type": "array",
104+ "items": {
105+ "type": "ref",
106+ "ref": "#individualLanguageSize"
107+ }
108+ }
109+ }
110+ },
111+ "individualLanguageSize": {
112+ "type": "object",
113+ "required": ["lang", "size"],
114+ "properties": {
115+ "lang": {
116+ "type": "string"
117+ },
118+ "size": {
119+ "type": "integer"
120+ }
121+ }
122+ },
123+ "commitCountBreakdown": {
124+ "type": "object",
125+ "required": [],
126+ "properties": {
127+ "byEmail": {
128+ "type": "array",
129+ "items": {
130+ "type": "ref",
131+ "ref": "#individualEmailCommitCount"
132+ }
133+ }
134+ }
135+ },
136+ "individualEmailCommitCount": {
137+ "type": "object",
138+ "required": ["email", "count"],
139+ "properties": {
140+ "email": {
141+ "type": "string"
142+ },
143+ "count": {
144+ "type": "integer"
145+ }
146+ }
147+ }
148+ }
149+}
+282,
-0
1@@ -0,0 +1,282 @@
2+{
3+ "lexicon": 1,
4+ "id": "sh.tangled.pipeline",
5+ "needsCbor": true,
6+ "needsType": true,
7+ "defs": {
8+ "main": {
9+ "type": "record",
10+ "key": "tid",
11+ "record": {
12+ "type": "object",
13+ "description": "DEPRECATED: use sh.tangled.ci.pipeline instead",
14+ "required": [
15+ "triggerMetadata",
16+ "workflows"
17+ ],
18+ "properties": {
19+ "triggerMetadata": {
20+ "type": "ref",
21+ "ref": "#triggerMetadata"
22+ },
23+ "workflows": {
24+ "type": "array",
25+ "items": {
26+ "type": "ref",
27+ "ref": "#workflow"
28+ }
29+ }
30+ }
31+ }
32+ },
33+ "triggerMetadata": {
34+ "type": "object",
35+ "required": [
36+ "kind",
37+ "repo"
38+ ],
39+ "properties": {
40+ "kind": {
41+ "type": "string",
42+ "enum": [
43+ "push",
44+ "pull_request",
45+ "manual",
46+ "schedule"
47+ ]
48+ },
49+ "repo": {
50+ "type": "ref",
51+ "ref": "#triggerRepo"
52+ },
53+ "push": {
54+ "type": "ref",
55+ "ref": "#pushTriggerData"
56+ },
57+ "pullRequest": {
58+ "type": "ref",
59+ "ref": "#pullRequestTriggerData"
60+ },
61+ "manual": {
62+ "type": "ref",
63+ "ref": "#manualTriggerData"
64+ },
65+ "schedule": {
66+ "type": "ref",
67+ "ref": "#scheduleTriggerData"
68+ },
69+ "sourceRepo": {
70+ "type": "string",
71+ "format": "did",
72+ "description": "Repository DID that code and workflow definitions are checked out from, when different from repo (e.g. a fork's commit for a fork-based manual trigger). If absent, source uses repo itself."
73+ }
74+ }
75+ },
76+ "triggerRepo": {
77+ "type": "object",
78+ "required": [
79+ "knot",
80+ "did",
81+ "defaultBranch"
82+ ],
83+ "properties": {
84+ "knot": {
85+ "type": "string"
86+ },
87+ "did": {
88+ "type": "string",
89+ "format": "did"
90+ },
91+ "repoDid": {
92+ "type": "string",
93+ "description": "DID of the repo itself",
94+ "format": "did"
95+ },
96+ "repo": {
97+ "type": "string"
98+ },
99+ "defaultBranch": {
100+ "type": "string"
101+ }
102+ }
103+ },
104+ "pushTriggerData": {
105+ "type": "object",
106+ "required": [
107+ "ref",
108+ "newSha",
109+ "oldSha"
110+ ],
111+ "properties": {
112+ "ref": {
113+ "type": "string"
114+ },
115+ "newSha": {
116+ "type": "string",
117+ "minLength": 40,
118+ "maxLength": 40
119+ },
120+ "oldSha": {
121+ "type": "string",
122+ "minLength": 40,
123+ "maxLength": 40
124+ }
125+ }
126+ },
127+ "pullRequestTriggerData": {
128+ "type": "object",
129+ "required": [
130+ "sourceBranch",
131+ "targetBranch",
132+ "sourceSha"
133+ ],
134+ "properties": {
135+ "action": {
136+ "type": "string",
137+ "description": "the pull request lifecycle action that produced this trigger",
138+ "enum": [
139+ "opened",
140+ "reopened",
141+ "closed",
142+ "merged",
143+ "synchronize"
144+ ]
145+ },
146+ "sourceBranch": {
147+ "type": "string"
148+ },
149+ "targetBranch": {
150+ "type": "string"
151+ },
152+ "sourceSha": {
153+ "type": "string",
154+ "minLength": 40,
155+ "maxLength": 40
156+ },
157+ "pull": {
158+ "type": "string",
159+ "format": "at-uri",
160+ "description": "AT-URI of the sh.tangled.repo.pull record this run belongs to"
161+ }
162+ }
163+ },
164+ "manualTriggerData": {
165+ "type": "object",
166+ "required": [
167+ "sha"
168+ ],
169+ "properties": {
170+ "sha": {
171+ "type": "string",
172+ "description": "commit SHA the manual run targets",
173+ "minLength": 40,
174+ "maxLength": 40
175+ },
176+ "ref": {
177+ "type": "string",
178+ "description": "optional ref the SHA was resolved from, for display and TANGLED_REF"
179+ },
180+ "inputs": {
181+ "type": "array",
182+ "items": {
183+ "type": "ref",
184+ "ref": "#pair"
185+ }
186+ }
187+ }
188+ },
189+ "scheduleTriggerData": {
190+ "type": "object",
191+ "required": [
192+ "sha",
193+ "ref",
194+ "scheduledAt"
195+ ],
196+ "properties": {
197+ "sha": {
198+ "type": "string",
199+ "description": "commit SHA the scheduled run targets",
200+ "minLength": 40,
201+ "maxLength": 40
202+ },
203+ "ref": {
204+ "type": "string",
205+ "description": "default branch ref resolved for the scheduled run"
206+ },
207+ "scheduledAt": {
208+ "type": "string",
209+ "format": "datetime",
210+ "description": "UTC minute selected by the cron schedule"
211+ }
212+ }
213+ },
214+ "workflow": {
215+ "type": "object",
216+ "required": [
217+ "name",
218+ "engine",
219+ "clone",
220+ "raw"
221+ ],
222+ "properties": {
223+ "name": {
224+ "type": "string"
225+ },
226+ "engine": {
227+ "type": "string"
228+ },
229+ "runsOn": {
230+ "type": "array",
231+ "items": {
232+ "type": "string"
233+ }
234+ },
235+ "clone": {
236+ "type": "ref",
237+ "ref": "#cloneOpts"
238+ },
239+ "raw": {
240+ "type": "string"
241+ }
242+ }
243+ },
244+ "cloneOpts": {
245+ "type": "object",
246+ "required": [
247+ "skip",
248+ "depth",
249+ "submodules",
250+ "tags"
251+ ],
252+ "properties": {
253+ "skip": {
254+ "type": "boolean"
255+ },
256+ "depth": {
257+ "type": "integer"
258+ },
259+ "submodules": {
260+ "type": "boolean"
261+ },
262+ "tags": {
263+ "type": "boolean"
264+ }
265+ }
266+ },
267+ "pair": {
268+ "type": "object",
269+ "required": [
270+ "key",
271+ "value"
272+ ],
273+ "properties": {
274+ "key": {
275+ "type": "string"
276+ },
277+ "value": {
278+ "type": "string"
279+ }
280+ }
281+ }
282+ }
283+}
1@@ -0,0 +1,54 @@
2+{
3+ "lexicon": 1,
4+ "id": "sh.tangled.pipeline.status",
5+ "needsCbor": true,
6+ "needsType": true,
7+ "defs": {
8+ "main": {
9+ "type": "record",
10+ "key": "tid",
11+ "record": {
12+ "type": "object",
13+ "description": "DEPRECATED: use sh.tangled.ci.pipeline instead",
14+ "required": ["pipeline", "workflow", "status", "createdAt"],
15+ "properties": {
16+ "pipeline": {
17+ "type": "string",
18+ "format": "at-uri",
19+ "description": "ATURI of the pipeline"
20+ },
21+ "workflow": {
22+ "type": "string",
23+ "format": "at-uri",
24+ "description": "name of the workflow within this pipeline"
25+ },
26+ "status": {
27+ "type": "string",
28+ "description": "status of the workflow",
29+ "enum": [
30+ "pending",
31+ "running",
32+ "failed",
33+ "timeout",
34+ "cancelled",
35+ "success"
36+ ]
37+ },
38+ "createdAt": {
39+ "type": "string",
40+ "format": "datetime",
41+ "description": "time of creation of this status update"
42+ },
43+ "error": {
44+ "type": "string",
45+ "description": "error message if failed"
46+ },
47+ "exitCode": {
48+ "type": "integer",
49+ "description": "exit code if failed"
50+ }
51+ }
52+ }
53+ }
54+ }
55+}