@jals1212/ci-local-pi 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +23 -2
- package/extensions/ci-local.ts +25 -2
- package/extensions/core.js +174 -3
- package/package.json +1 -1
- package/skills/ci-local/SKILL.md +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,25 @@ add or change behaviour and a patch version fixes.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.5.0
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The status line of a finished run says when it ended, so an old result is
|
|
14
|
+
not taken for a new one: how long ago for its first hour
|
|
15
|
+
(`🚦 ci-local · ✗ failed at lint · 32s · 10m ago`), then the local time it
|
|
16
|
+
ended (`· 13:10`). (#58)
|
|
17
|
+
|
|
18
|
+
## 0.4.0
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Herdr sidebar: in a Herdr pane, the status line is also the pane's `$ci`
|
|
23
|
+
metadata token while a run is in flight and for 5 minutes after it ends, so
|
|
24
|
+
a `["$ci"]` row in Herdr's Agents sidebar shows ci-local next to the
|
|
25
|
+
agent. Display only, refreshed with a 30-second TTL, and cleared when the
|
|
26
|
+
session ends. (#54)
|
|
27
|
+
|
|
9
28
|
## 0.3.1
|
|
10
29
|
|
|
11
30
|
### Fixed
|
package/README.md
CHANGED
|
@@ -24,8 +24,9 @@ literal `CI_PROJECT=`), it follows the events file of that project; it never
|
|
|
24
24
|
runs ci-local or Docker itself:
|
|
25
25
|
|
|
26
26
|
- **Status line**: `🚦 ci-local · ◌ queued · 2 ahead (erp-ci running)`, `🚦 ci-local · ⧗ [3/8] lint · 2m05s`,
|
|
27
|
-
then `🚦 ci-local · ✓ passed · 4m12s` or `🚦 ci-local · ✗ failed at lint · 2m10s`
|
|
28
|
-
the next run. A
|
|
27
|
+
then `🚦 ci-local · ✓ passed · 4m12s · 10m ago` or `🚦 ci-local · ✗ failed at lint · 2m10s · just now`
|
|
28
|
+
until the next run. A result says how long ago it ended for its first hour,
|
|
29
|
+
then the local time it ended (`· 13:10`). A run already over when a session starts shows only while its
|
|
29
30
|
result is undelivered, like an unread message: it ended with no session of
|
|
30
31
|
the repository open. Once a session has shown it finished, a later session
|
|
31
32
|
(new or resumed) leaves it out, and a reload keeps it out unless that
|
|
@@ -35,6 +36,26 @@ runs ci-local or Docker itself:
|
|
|
35
36
|
shows it where it lists extension statuses: Gentle Shell, for one, under
|
|
36
37
|
**Integrations** in the sidebar's Status card (or in its bottom bar on a
|
|
37
38
|
narrow terminal).
|
|
39
|
+
- **Herdr sidebar**: in a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`,
|
|
40
|
+
`HERDR_PANE_ID`, `HERDR_SOCKET_PATH`), the status line is also the pane's
|
|
41
|
+
`ci` metadata token while a run is queued or running, and its result for 5
|
|
42
|
+
minutes after it ends. The token is display only: the agent's state in
|
|
43
|
+
Herdr does not change. It is reported again every 10 seconds with a
|
|
44
|
+
30-second TTL, so it goes away when Pi does. To show it, add a `$ci` row to
|
|
45
|
+
the Agents sidebar in Herdr's `config.toml`, then run `herdr server reload-config`:
|
|
46
|
+
|
|
47
|
+
```toml
|
|
48
|
+
[ui.sidebar.agents]
|
|
49
|
+
rows = [
|
|
50
|
+
["state_icon", "machine", "workspace", "tab"],
|
|
51
|
+
["agent"],
|
|
52
|
+
[{ token = "$ci", rules = [
|
|
53
|
+
{ contains = "✗", fg = "#f38ba8" },
|
|
54
|
+
{ contains = "✓", fg = "#a6e3a1" },
|
|
55
|
+
{ contains = "⧗", fg = "#f9e2af" },
|
|
56
|
+
] }],
|
|
57
|
+
]
|
|
58
|
+
```
|
|
38
59
|
- **Only the session's own runs** get the cards and the notification, and
|
|
39
60
|
reach the agent: runs this session started. A run started from Pi's bash
|
|
40
61
|
tool names its session (ci-local records Pi's `PI_SESSION_ID` as `session`
|
package/extensions/ci-local.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// ci-local run in Pi's own UI, so neither the agent nor you write it by hand.
|
|
3
3
|
//
|
|
4
4
|
// - Status line: `🚦 ci-local · ⧗ [3/8] lint · 2m05s`, `🚦 ci-local · ◌ queued ·
|
|
5
|
-
// 2 ahead (erp-ci running)`, then `🚦 ci-local · ✓ passed · 4m12s` / `🚦 ci-local ·
|
|
6
|
-
// ✗ failed at lint
|
|
5
|
+
// 2 ahead (erp-ci running)`, then `🚦 ci-local · ✓ passed · 4m12s · 10m ago` / `🚦 ci-local ·
|
|
6
|
+
// ✗ failed at lint · 2m10s · 13:10` (how long ago it ended, then from an
|
|
7
|
+
// hour on the time it ended) until the next run (🚦 is ci-local's own emoji, see BRAND).
|
|
7
8
|
// A run already over when a session starts is shown only if no session
|
|
8
9
|
// showed its result yet, like a message not yet delivered (createStatusLine).
|
|
9
10
|
// - Cards and the agent: only for runs this session started, as Gentle AI's job
|
|
@@ -13,6 +14,9 @@
|
|
|
13
14
|
// this session's process (a background job, even orphaned). Another
|
|
14
15
|
// session's run in the same repository, or one started in a terminal, shows
|
|
15
16
|
// in the status line alone.
|
|
17
|
+
// - Herdr: in a pane Herdr runs, the status line is also the pane's `$ci`
|
|
18
|
+
// metadata token while a run is in flight and for 5 minutes after it ends,
|
|
19
|
+
// so a `["$ci"]` row in Herdr's Agents sidebar shows it (display only).
|
|
16
20
|
// - When a run starts: a card in the transcript when it is starting (or
|
|
17
21
|
// waiting for its turn), another when its steps start; for you only (session
|
|
18
22
|
// entries, never in the model's context, never repeated after a reload).
|
|
@@ -42,6 +46,8 @@ import {
|
|
|
42
46
|
LAUNCH_WAIT_MS,
|
|
43
47
|
WAKE_TEXT,
|
|
44
48
|
cardContent,
|
|
49
|
+
createHerdrLine,
|
|
50
|
+
createHerdrPublisher,
|
|
45
51
|
createDelivery,
|
|
46
52
|
createStatusLine,
|
|
47
53
|
createTracker,
|
|
@@ -49,6 +55,8 @@ import {
|
|
|
49
55
|
eventsFileFor,
|
|
50
56
|
exitCard,
|
|
51
57
|
findProject,
|
|
58
|
+
herdrPane,
|
|
59
|
+
herdrSend,
|
|
52
60
|
launchReport,
|
|
53
61
|
launchRun,
|
|
54
62
|
ownsRun,
|
|
@@ -98,6 +106,10 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
|
|
|
98
106
|
// belongs to the session that started it; another session drops it.
|
|
99
107
|
type Launch = ReturnType<typeof launchRun> & { seen: boolean; waiting: boolean; sessionId: string }
|
|
100
108
|
const launches = new Map<number, Launch>()
|
|
109
|
+
// In a Herdr pane, the footer line is also the pane's `$ci` sidebar token
|
|
110
|
+
// (see createHerdrLine in core.js); null elsewhere. Read when a session
|
|
111
|
+
// starts following, so a test or a moved pane gets its own.
|
|
112
|
+
let herdr: { pane: string; socket: string; publisher: ReturnType<typeof createHerdrPublisher> } | undefined
|
|
101
113
|
|
|
102
114
|
pi.on('agent_start', async () => {
|
|
103
115
|
runActive = true
|
|
@@ -249,6 +261,11 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
|
|
|
249
261
|
// already showed, and records each finished run it shows (see
|
|
250
262
|
// createStatusLine in core.js).
|
|
251
263
|
const statusLine = createStatusLine({ file, sessionId, reason })
|
|
264
|
+
const pane = herdrPane(process.env)
|
|
265
|
+
if (pane && (herdr?.pane !== pane.pane || herdr.socket !== pane.socket)) {
|
|
266
|
+
herdr = { ...pane, publisher: createHerdrPublisher({ send: herdrSend(pane) }) }
|
|
267
|
+
}
|
|
268
|
+
const herdrLine = pane ? createHerdrLine() : undefined
|
|
252
269
|
|
|
253
270
|
const tick = () => {
|
|
254
271
|
let run
|
|
@@ -259,6 +276,7 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
|
|
|
259
276
|
}
|
|
260
277
|
const { footer, changed } = statusLine.update(run)
|
|
261
278
|
if (changed) ctx.ui.setStatus(KEY, footer)
|
|
279
|
+
if (herdrLine) herdr?.publisher.update(herdrLine.text(run))
|
|
262
280
|
settleLaunches(run)
|
|
263
281
|
// Only this session's own runs get cards and reach the agent, as Gentle
|
|
264
282
|
// AI's job notices go only to the session that started the job. Another
|
|
@@ -336,6 +354,11 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
|
|
|
336
354
|
|
|
337
355
|
pi.on('session_shutdown', async () => {
|
|
338
356
|
stop()
|
|
357
|
+
// The sidebar token goes with the session (its TTL would end it anyway).
|
|
358
|
+
if (herdr) {
|
|
359
|
+
herdr.publisher.update(null)
|
|
360
|
+
await herdr.publisher.idle()
|
|
361
|
+
}
|
|
339
362
|
startReason = undefined
|
|
340
363
|
delete (globalThis as Record<symbol, unknown>)[FLAG]
|
|
341
364
|
})
|
package/extensions/core.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
statSync,
|
|
20
20
|
writeFileSync,
|
|
21
21
|
} from 'node:fs'
|
|
22
|
+
import { createConnection } from 'node:net'
|
|
22
23
|
import { tmpdir } from 'node:os'
|
|
23
24
|
import { dirname, isAbsolute, join } from 'node:path'
|
|
24
25
|
|
|
@@ -284,10 +285,30 @@ export function createStatusLine({ file, sessionId, reason }) {
|
|
|
284
285
|
}
|
|
285
286
|
}
|
|
286
287
|
|
|
287
|
-
/**
|
|
288
|
-
|
|
288
|
+
/**
|
|
289
|
+
* One short line for the footer: `🚦 ci-local · ✓ passed · 4m12s · 10m ago`. A
|
|
290
|
+
* finished run says when it ended (from its end event), so an old result is
|
|
291
|
+
* not taken for a new one: how long ago within the first hour, then the local
|
|
292
|
+
* HH:MM, which no longer changes. `now` is the time it is said at.
|
|
293
|
+
*/
|
|
294
|
+
export function footerText(state, now = Date.now()) {
|
|
289
295
|
// The state's glyph sits by the state, after the name.
|
|
290
|
-
|
|
296
|
+
const line = `${BRAND} ci-local · ${GLYPHS[state.state] ?? GLYPHS.stopped} ${stateText(state)}`
|
|
297
|
+
const at = FINISHED.has(state.state) ? endedText(state.end?.ts, now) : null
|
|
298
|
+
return at ? `${line} · ${at}` : line
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Until then a result says how long ago it ended; after, the time it ended. */
|
|
302
|
+
export const RELATIVE_END_MS = 60 * 60_000
|
|
303
|
+
|
|
304
|
+
/** When a run ended, as the footer says it, or null without an end time. */
|
|
305
|
+
function endedText(ts, now) {
|
|
306
|
+
const date = new Date(ts ?? '')
|
|
307
|
+
if (Number.isNaN(date.getTime())) return null
|
|
308
|
+
const age = now - date.getTime()
|
|
309
|
+
if (age < 60_000) return 'just now' // a clock a little ahead says so too
|
|
310
|
+
if (age < RELATIVE_END_MS) return `${Math.floor(age / 60_000)}m ago`
|
|
311
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
|
291
312
|
}
|
|
292
313
|
|
|
293
314
|
/** The state of a run in a few words, as the footer says it after its glyph. */
|
|
@@ -663,3 +684,153 @@ export function exitCard({ pid, code, signal = null, output, tail = outputTail(o
|
|
|
663
684
|
},
|
|
664
685
|
}
|
|
665
686
|
}
|
|
687
|
+
|
|
688
|
+
// Herdr (https://herdr.dev): in a pane Herdr runs, the footer line is also the
|
|
689
|
+
// pane's `$ci` metadata token, so a row `["$ci"]` in Herdr's Agents sidebar
|
|
690
|
+
// shows the run. A token is display only: it never touches the agent's state.
|
|
691
|
+
|
|
692
|
+
/** The token's name, as a sidebar row names it: `$ci`. */
|
|
693
|
+
export const HERDR_TOKEN = 'ci'
|
|
694
|
+
/** Who reports the token. No sequence numbers: one sender, one report at a time. */
|
|
695
|
+
export const HERDR_SOURCE = 'ci-local'
|
|
696
|
+
/** Herdr drops the token this long after the last report: a Pi gone leaves nothing behind. */
|
|
697
|
+
export const HERDR_TTL_MS = 30_000
|
|
698
|
+
/** A token unchanged is reported again this often, well inside its TTL. */
|
|
699
|
+
export const HERDR_REFRESH_MS = 10_000
|
|
700
|
+
/** How long a run's result stays in the sidebar after the run ends. */
|
|
701
|
+
export const HERDR_RESULT_MS = 5 * 60_000
|
|
702
|
+
/** Herdr keeps at most this many characters of a token. */
|
|
703
|
+
export const HERDR_TOKEN_MAX = 80
|
|
704
|
+
|
|
705
|
+
/** The pane and socket of the Herdr pane this process runs in, or null outside one. */
|
|
706
|
+
export function herdrPane(env = process.env) {
|
|
707
|
+
const pane = env.HERDR_PANE_ID?.trim()
|
|
708
|
+
const socket = env.HERDR_SOCKET_PATH?.trim()
|
|
709
|
+
return env.HERDR_ENV === '1' && pane && socket ? { pane, socket } : null
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function clip(text) {
|
|
713
|
+
const chars = [...text]
|
|
714
|
+
return chars.length <= HERDR_TOKEN_MAX ? text : chars.slice(0, HERDR_TOKEN_MAX - 1).join('') + '…'
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* What the token says for a run: the footer line while it is in flight, and
|
|
719
|
+
* its result for HERDR_RESULT_MS after it ended (by its end event; a run that
|
|
720
|
+
* stopped without one, from when this session saw it stop). Null otherwise:
|
|
721
|
+
* no run, or a result older than that, or a run found already stopped.
|
|
722
|
+
*/
|
|
723
|
+
export function createHerdrLine({ now = Date.now } = {}) {
|
|
724
|
+
let flying // the key of the last run seen in flight
|
|
725
|
+
const endedAt = new Map()
|
|
726
|
+
return {
|
|
727
|
+
text(run) {
|
|
728
|
+
if (!run) return null
|
|
729
|
+
if (!FINISHED.has(run.state.state)) {
|
|
730
|
+
flying = run.key
|
|
731
|
+
return clip(footerText(run.state))
|
|
732
|
+
}
|
|
733
|
+
if (!endedAt.has(run.key)) {
|
|
734
|
+
const ended = Date.parse(run.state.end?.ts ?? '')
|
|
735
|
+
endedAt.clear()
|
|
736
|
+
endedAt.set(run.key, Number.isFinite(ended) ? ended : run.key === flying ? now() : null)
|
|
737
|
+
}
|
|
738
|
+
const ended = endedAt.get(run.key)
|
|
739
|
+
return ended !== null && now() - ended < HERDR_RESULT_MS ? clip(footerText(run.state, now())) : null
|
|
740
|
+
},
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** The socket request that sets the token (with its TTL), or clears it (null). */
|
|
745
|
+
export function herdrReport(pane, value) {
|
|
746
|
+
const params = { pane_id: pane, source: HERDR_SOURCE, tokens: { [HERDR_TOKEN]: value } }
|
|
747
|
+
if (value !== null) params.ttl_ms = HERDR_TTL_MS
|
|
748
|
+
return { method: 'pane.report_metadata', params }
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
let herdrRequests = 0
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* A sender of the token to Herdr's socket (one JSON line, one reply): resolves
|
|
755
|
+
* true when Herdr accepted it, false on any error or after `timeoutMs`.
|
|
756
|
+
*/
|
|
757
|
+
export function herdrSend({ pane, socket }, { timeoutMs = 2000 } = {}) {
|
|
758
|
+
return (value) =>
|
|
759
|
+
new Promise((resolve) => {
|
|
760
|
+
const id = `ci-local-${process.pid}-${++herdrRequests}`
|
|
761
|
+
let reply = ''
|
|
762
|
+
let done = false
|
|
763
|
+
const finish = (ok) => {
|
|
764
|
+
if (done) return
|
|
765
|
+
done = true
|
|
766
|
+
clearTimeout(timer)
|
|
767
|
+
conn.destroy()
|
|
768
|
+
resolve(ok)
|
|
769
|
+
}
|
|
770
|
+
const conn = createConnection(socket)
|
|
771
|
+
const timer = setTimeout(() => finish(false), timeoutMs)
|
|
772
|
+
timer.unref?.()
|
|
773
|
+
conn.on('connect', () => conn.write(JSON.stringify({ id, ...herdrReport(pane, value) }) + '\n'))
|
|
774
|
+
conn.on('data', (data) => {
|
|
775
|
+
reply += data
|
|
776
|
+
const end = reply.indexOf('\n')
|
|
777
|
+
if (end < 0) return
|
|
778
|
+
try {
|
|
779
|
+
const answer = JSON.parse(reply.slice(0, end))
|
|
780
|
+
finish(answer.id === id && answer.result?.type === 'ok')
|
|
781
|
+
} catch {
|
|
782
|
+
finish(false)
|
|
783
|
+
}
|
|
784
|
+
})
|
|
785
|
+
conn.on('error', () => finish(false))
|
|
786
|
+
conn.on('close', () => finish(false))
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Keeps Herdr's token in step with `update(value)` (null clears it): a report
|
|
792
|
+
* when the value changes, again every HERDR_REFRESH_MS while it stays, one
|
|
793
|
+
* report at a time (the latest value waits for the one on its way). A failed
|
|
794
|
+
* report is tried again at the next update. `idle()` resolves once nothing is
|
|
795
|
+
* on its way.
|
|
796
|
+
*/
|
|
797
|
+
export function createHerdrPublisher({ send, now = Date.now, refreshMs = HERDR_REFRESH_MS }) {
|
|
798
|
+
let shown = null // what Herdr has
|
|
799
|
+
let sentAt = 0
|
|
800
|
+
let inFlight = null
|
|
801
|
+
let pending // the value to report once inFlight is done
|
|
802
|
+
const update = (value) => {
|
|
803
|
+
if (inFlight) {
|
|
804
|
+
pending = value
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
const due = value !== shown || (value !== null && now() - sentAt >= refreshMs)
|
|
808
|
+
if (!due) return
|
|
809
|
+
let sending
|
|
810
|
+
try {
|
|
811
|
+
sending = Promise.resolve(send(value))
|
|
812
|
+
} catch {
|
|
813
|
+
sending = Promise.resolve(false)
|
|
814
|
+
}
|
|
815
|
+
inFlight = sending
|
|
816
|
+
.catch(() => false)
|
|
817
|
+
.then((ok) => {
|
|
818
|
+
if (ok) {
|
|
819
|
+
shown = value
|
|
820
|
+
sentAt = now()
|
|
821
|
+
}
|
|
822
|
+
inFlight = null
|
|
823
|
+
if (pending !== undefined) {
|
|
824
|
+
const next = pending
|
|
825
|
+
pending = undefined
|
|
826
|
+
update(next)
|
|
827
|
+
}
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
return {
|
|
831
|
+
update,
|
|
832
|
+
async idle() {
|
|
833
|
+
while (inFlight) await inFlight
|
|
834
|
+
},
|
|
835
|
+
}
|
|
836
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jals1212/ci-local-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Pi extension and skill for @jals1212/ci-local: the state of this repository's ci-local run in Pi's status line, cards and a read-only tool for the agent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "jals1212",
|
package/skills/ci-local/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: "Trigger: ci-local, run CI locally, ci-local.config.sh, CI_PROJECT,
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: jals1212
|
|
7
|
-
version: "0.
|
|
7
|
+
version: "0.5.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
## Activation Contract
|
|
@@ -18,7 +18,7 @@ Use when running, waiting on, reading, or configuring `ci-local` (`npx ci-local`
|
|
|
18
18
|
- Unwatched runs are quiet: milestones and the summary on stdout, each step's output in its own log. From 0.5.0 that is the default without a terminal; before 0.5.0, add `--quiet`. Never add `--live` to a background run.
|
|
19
19
|
- Do not touch the checkout while a run is in flight (no edits, no branch switch): the steps read it.
|
|
20
20
|
- Never start a second run to "check" the first. Runs queue machine-wide by themselves (one Docker user at a time); a waiting run is normal, not a hang.
|
|
21
|
-
- Read the result from the run itself: `PASSED:` / `FAILED at step:` and `Development stack untouched`. A changed development stack is a failure.
|
|
21
|
+
- Read the result from the run itself: `PASSED:` / `FAILED at step:` / `STOPPED` (stopped on purpose, not a failure) and `Development stack untouched`. A changed development stack is a failure.
|
|
22
22
|
- On failure, read only the failing step's log (named in the failure output or `--status`), not every log.
|
|
23
23
|
- Everything a run creates carries the `$CI_PROJECT` prefix and the `$CI_LABEL` label; never hard-code another name, publish a port, or reuse a development container name. Local compose smokes use `-p <own-name>`.
|
|
24
24
|
|
|
@@ -45,7 +45,7 @@ Use when running, waiting on, reading, or configuring `ci-local` (`npx ci-local`
|
|
|
45
45
|
|
|
46
46
|
## Output Contract
|
|
47
47
|
|
|
48
|
-
Report: project, commit, result line (`PASSED` / `FAILED at step`), total time, any `DEVIATES` lines, and whether the development stack was untouched.
|
|
48
|
+
Report: project, commit, result line (`PASSED` / `FAILED at step` / `STOPPED`), total time, any `DEVIATES` lines, and whether the development stack was untouched.
|
|
49
49
|
|
|
50
50
|
## References
|
|
51
51
|
|