Skip to content
267 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.1/**
2 * Colour in a job's log, as a terminal draws it. Tools colour their output
3 * when they see `GITHUB_ACTIONS=true` (chalk, cargo, pytest and the like),
4 * with SGR escape codes: bold, dim, italic, underline, the 16 colours, the
5 * 256-colour palette and 24-bit colour, for text and its background. Those
6 * are read into styled spans; every other escape sequence (moving the
7 * cursor, clearing the line, setting the window's title) is dropped, and a
8 * hyperlink (OSC 8) keeps its text. The 16 colours are theme tokens
9 * (`ansi-fg-N` and `ansi-bg-N` in app.css), so they read in light and dark.
10 */
11
12/** A colour: one of the 256 of the palette, or red, green and blue. */
13export type AnsiColor = { palette: number } | { rgb: [number, number, number] };
14
15export type AnsiStyle = {
16 bold?: boolean;
17 dim?: boolean;
18 italic?: boolean;
19 underline?: boolean;
20 strike?: boolean;
21 inverse?: boolean;
22 fg?: AnsiColor;
23 bg?: AnsiColor;
24};
25
26export type AnsiSpan = { text: string; style: AnsiStyle };
27
28const ESC = "\u001b";
29
30/** Whether `text` holds an escape sequence or another control character worth reading out. */
31export function hasEscapes(text: string): boolean {
32 // eslint-disable-next-line no-control-regex
33 return /[\u0000-\u0008\u000b-\u001f\u007f\u009b]/.test(text);
34}
35
36/** `text` without its escape sequences: what a search reads and a copy takes. */
37export function stripAnsi(text: string): string {
38 if (!hasEscapes(text)) return text;
39 return parseAnsi(text)
40 .map((span) => span.text)
41 .join("");
42}
43
44/**
45 * A parameter in ITU's colon form as its `;` form: `38:5:n`, `38:2::r:g:b`
46 * (the empty one names the colour space), and `4:3`, a curly underline,
47 * as an underline.
48 */
49function colonForm(part: string): string[] {
50 if (!part.includes(":")) return [part];
51 const pieces = part.split(":");
52 if ((pieces[0] === "38" || pieces[0] === "48") && pieces[1] === "2" && pieces.length >= 6) return [pieces[0], "2", ...pieces.slice(3, 6)];
53 if (pieces[0] === "4") return [pieces[1] === "0" ? "24" : "4"];
54 return pieces;
55}
56
57/** Reads one SGR sequence's parameters into `style`, in place. */
58function applySgr(style: AnsiStyle, params: string): void {
59 const codes = (params === "" ? "0" : params)
60 .split(";")
61 .flatMap(colonForm)
62 .map((part) => (part === "" ? 0 : Number(part)));
63 for (let i = 0; i < codes.length; i++) {
64 const code = codes[i]!;
65 if (!Number.isFinite(code)) continue;
66 if (code === 0) {
67 for (const key of Object.keys(style) as (keyof AnsiStyle)[]) delete style[key];
68 } else if (code === 1) style.bold = true;
69 else if (code === 2) style.dim = true;
70 else if (code === 3) style.italic = true;
71 else if (code === 4 || code === 21) style.underline = true;
72 else if (code === 7) style.inverse = true;
73 else if (code === 9) style.strike = true;
74 else if (code === 22) {
75 delete style.bold;
76 delete style.dim;
77 } else if (code === 23) delete style.italic;
78 else if (code === 24) delete style.underline;
79 else if (code === 27) delete style.inverse;
80 else if (code === 29) delete style.strike;
81 else if (code >= 30 && code <= 37) style.fg = { palette: code - 30 };
82 else if (code === 39) delete style.fg;
83 else if (code >= 40 && code <= 47) style.bg = { palette: code - 40 };
84 else if (code === 49) delete style.bg;
85 else if (code >= 90 && code <= 97) style.fg = { palette: code - 90 + 8 };
86 else if (code >= 100 && code <= 107) style.bg = { palette: code - 100 + 8 };
87 else if (code === 38 || code === 48) {
88 const key = code === 38 ? "fg" : "bg";
89 const kind = codes[i + 1];
90 if (kind === 5) {
91 const n = codes[i + 2];
92 if (n !== undefined && n >= 0 && n <= 255) style[key] = { palette: n };
93 i += 2;
94 } else if (kind === 2) {
95 const [r, g, b] = [codes[i + 2], codes[i + 3], codes[i + 4]];
96 if ([r, g, b].every((n) => n !== undefined && n >= 0 && n <= 255)) style[key] = { rgb: [r!, g!, b!] };
97 i += 4;
98 }
99 }
100 }
101}
102
103/**
104 * One line of a log as styled spans, in order, with every escape
105 * sequence read or dropped. A line starts unstyled, as each line of
106 * GitHub's log does.
107 */
108export function parseAnsi(text: string): AnsiSpan[] {
109 const spans: AnsiSpan[] = [];
110 const style: AnsiStyle = {};
111 let pending = "";
112 const push = () => {
113 if (!pending) return;
114 const last = spans[spans.length - 1];
115 if (last && sameStyle(last.style, style)) last.text += pending;
116 else spans.push({ text: pending, style: { ...style } });
117 pending = "";
118 };
119 let i = 0;
120 while (i < text.length) {
121 const char = text[i]!;
122 const code = char.charCodeAt(0);
123 if (char === ESC || code === 0x9b) {
124 const csi = code === 0x9b || text[i + 1] === "[";
125 if (csi) {
126 // CSI: parameters, intermediates, then one final byte.
127 let j = i + (code === 0x9b ? 1 : 2);
128 const start = j;
129 while (j < text.length && text.charCodeAt(j) >= 0x30 && text.charCodeAt(j) <= 0x3f) j++;
130 const params = text.slice(start, j);
131 while (j < text.length && text.charCodeAt(j) >= 0x20 && text.charCodeAt(j) <= 0x2f) j++;
132 const final = text[j];
133 if (final === "m" && /^[\d;:]*$/.test(params)) {
134 push();
135 applySgr(style, params);
136 }
137 i = final === undefined ? j : j + 1;
138 continue;
139 }
140 const next = text[i + 1];
141 if (next === "]" || next === "P" || next === "X" || next === "^" || next === "_") {
142 // OSC and the other strings: to BEL or ESC \. An OSC 8 hyperlink
143 // is two of these around its text, which stays.
144 let j = i + 2;
145 while (j < text.length && text[j] !== "\u0007" && !(text[j] === ESC && text[j + 1] === "\\")) j++;
146 i = j >= text.length ? j : text[j] === "\u0007" ? j + 1 : j + 2;
147 continue;
148 }
149 // ESC ( B and its kin pick a character set: three characters.
150 if (next !== undefined && "()*+-./#%".includes(next)) {
151 i += 3;
152 continue;
153 }
154 // Any other escape is two characters (ESC 7, ESC =, ESC M…).
155 i += next === undefined ? 1 : 2;
156 continue;
157 }
158 // Other control characters (a bell, a backspace) draw nothing; a tab stays.
159 if ((code < 0x20 && char !== "\t") || code === 0x7f) {
160 i++;
161 continue;
162 }
163 pending += char;
164 i++;
165 }
166 push();
167 return spans;
168}
169
170function sameColor(a: AnsiColor | undefined, b: AnsiColor | undefined): boolean {
171 if (!a || !b) return a === b;
172 if ("palette" in a) return "palette" in b && a.palette === b.palette;
173 return "rgb" in b && a.rgb.every((n, index) => n === b.rgb[index]);
174}
175
176function sameStyle(a: AnsiStyle, b: AnsiStyle): boolean {
177 return (
178 !!a.bold === !!b.bold &&
179 !!a.dim === !!b.dim &&
180 !!a.italic === !!b.italic &&
181 !!a.underline === !!b.underline &&
182 !!a.strike === !!b.strike &&
183 !!a.inverse === !!b.inverse &&
184 sameColor(a.fg, b.fg) &&
185 sameColor(a.bg, b.bg)
186 );
187}
188
189/** A colour of the 256-colour palette past the first 16, as red, green and blue. */
190export function paletteRgb(n: number): [number, number, number] {
191 if (n >= 232) {
192 const level = 8 + (n - 232) * 10;
193 return [level, level, level];
194 }
195 const index = n - 16;
196 const step = (value: number) => (value === 0 ? 0 : 55 + value * 40);
197 return [step(Math.floor(index / 36)), step(Math.floor(index / 6) % 6), step(index % 6)];
198}
199
200function cssColor(color: AnsiColor): { token: number } | { css: string } {
201 if ("palette" in color) {
202 if (color.palette < 16) return { token: color.palette };
203 const [r, g, b] = paletteRgb(color.palette);
204 return { css: `rgb(${r}, ${g}, ${b})` };
205 }
206 return { css: `rgb(${color.rgb.join(", ")})` };
207}
208
209/**
210 * How a span draws: classes for what the theme decides (the 16 colours,
211 * weight, slant, lines) and inline colours for the rest.
212 */
213export function spanLook(style: AnsiStyle): { className: string; style?: { color?: string; backgroundColor?: string } } {
214 const classes: string[] = [];
215 const inline: { color?: string; backgroundColor?: string } = {};
216 const [fg, bg] = style.inverse ? [style.bg, style.fg] : [style.fg, style.bg];
217 if (fg) {
218 const look = cssColor(fg);
219 if ("token" in look) classes.push(`ansi-fg-${look.token}`);
220 else inline.color = look.css;
221 } else if (style.inverse) classes.push("ansi-fg-inverse");
222 if (bg) {
223 const look = cssColor(bg);
224 if ("token" in look) classes.push(`ansi-bg-${look.token}`);
225 else inline.backgroundColor = look.css;
226 } else if (style.inverse) classes.push("ansi-bg-inverse");
227 if (style.bold) classes.push("font-bold");
228 if (style.dim) classes.push("opacity-70");
229 if (style.italic) classes.push("italic");
230 if (style.underline && style.strike) classes.push("[text-decoration-line:underline_line-through]");
231 else if (style.underline) classes.push("underline");
232 else if (style.strike) classes.push("line-through");
233 return { className: classes.join(" "), style: inline.color || inline.backgroundColor ? inline : undefined };
234}
235
236/**
237 * Spans cut where `query` (any case) appears in their text taken
238 * together, each piece marked as a match or not, so a search marks what
239 * it found without losing the colours around it.
240 */
241export function markSpans(spans: AnsiSpan[], query: string): (AnsiSpan & { match: boolean })[] {
242 const wanted = query.trim().toLowerCase();
243 if (!wanted) return spans.map((span) => ({ ...span, match: false }));
244 const plain = spans.map((span) => span.text).join("");
245 const lower = plain.toLowerCase();
246 // Where matches start and end, in the plain text.
247 const ranges: [number, number][] = [];
248 for (let found = lower.indexOf(wanted); found !== -1; found = lower.indexOf(wanted, found + wanted.length)) {
249 ranges.push([found, found + wanted.length]);
250 }
251 if (ranges.length === 0) return spans.map((span) => ({ ...span, match: false }));
252 const out: (AnsiSpan & { match: boolean })[] = [];
253 let at = 0;
254 for (const span of spans) {
255 const end = at + span.text.length;
256 let cursor = at;
257 while (cursor < end) {
258 const inside = ranges.find(([from, to]) => cursor >= from && cursor < to);
259 const nextStart = ranges.find(([from]) => from > cursor)?.[0] ?? Infinity;
260 const stop = Math.min(end, inside ? inside[1] : nextStart);
261 out.push({ text: plain.slice(cursor, stop), style: span.style, match: Boolean(inside) });
262 cursor = stop;
263 }
264 at = end;
265 }
266 return out;
267}