| 1 | /** |
| 2 | * GitHub Actions workflow commands (`::name k=v::message` on stdout) and file |
| 3 | * commands (`$GITHUB_ENV`, `$GITHUB_OUTPUT`, ... files). |
| 4 | */ |
| 5 | |
| 6 | export interface WorkflowCommand { |
| 7 | command: string; |
| 8 | properties: Record<string, string>; |
| 9 | message: string; |
| 10 | } |
| 11 | |
| 12 | export function unescapeData(s: string): string { |
| 13 | return s.replace(/%0D/gi, "\r").replace(/%0A/gi, "\n").replace(/%25/g, "%"); |
| 14 | } |
| 15 | |
| 16 | export function unescapeProperty(s: string): string { |
| 17 | return s |
| 18 | .replace(/%0D/gi, "\r") |
| 19 | .replace(/%0A/gi, "\n") |
| 20 | .replace(/%3A/gi, ":") |
| 21 | .replace(/%2C/gi, ",") |
| 22 | .replace(/%25/g, "%"); |
| 23 | } |
| 24 | |
| 25 | /** Parses a `::command props::message` line. Returns null if not a command. */ |
| 26 | export function parseCommand(line: string): WorkflowCommand | null { |
| 27 | const trimmed = line.replace(/^\s+/, ""); |
| 28 | if (!trimmed.startsWith("::")) return null; |
| 29 | const end = trimmed.indexOf("::", 2); |
| 30 | if (end < 0) return null; |
| 31 | const head = trimmed.slice(2, end); |
| 32 | const message = trimmed.slice(end + 2); |
| 33 | const sp = head.indexOf(" "); |
| 34 | const command = (sp < 0 ? head : head.slice(0, sp)).trim(); |
| 35 | if (!command || !/^[A-Za-z0-9_.-]+$/.test(command)) return null; |
| 36 | const properties: Record<string, string> = {}; |
| 37 | if (sp >= 0) { |
| 38 | for (const part of head.slice(sp + 1).split(",")) { |
| 39 | const eq = part.indexOf("="); |
| 40 | if (eq <= 0) continue; |
| 41 | properties[part.slice(0, eq).trim()] = unescapeProperty(part.slice(eq + 1)); |
| 42 | } |
| 43 | } |
| 44 | return { command, properties, message: unescapeData(message) }; |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * Parses an env-file style file command (GITHUB_ENV / GITHUB_OUTPUT / |
| 49 | * GITHUB_STATE): `name=value` lines and `name<<DELIM` heredocs. |
| 50 | */ |
| 51 | export function parseKeyValueFile(content: string): Array<[string, string]> { |
| 52 | const out: Array<[string, string]> = []; |
| 53 | if (!content) return out; |
| 54 | const lines = content.split(/\r?\n/); |
| 55 | for (let i = 0; i < lines.length; i++) { |
| 56 | const line = lines[i]!; |
| 57 | if (line === "") continue; |
| 58 | const eq = line.indexOf("="); |
| 59 | const hd = line.indexOf("<<"); |
| 60 | if (hd > 0 && (eq < 0 || hd < eq)) { |
| 61 | const name = line.slice(0, hd); |
| 62 | const delim = line.slice(hd + 2); |
| 63 | if (!name || !delim) throw new Error(`Invalid file command format: ${line}`); |
| 64 | const buf: string[] = []; |
| 65 | let found = false; |
| 66 | for (i++; i < lines.length; i++) { |
| 67 | if (lines[i] === delim) { |
| 68 | found = true; |
| 69 | break; |
| 70 | } |
| 71 | buf.push(lines[i]!); |
| 72 | } |
| 73 | if (!found) throw new Error(`Matching delimiter not found '${delim}'`); |
| 74 | out.push([name, buf.join("\n")]); |
| 75 | continue; |
| 76 | } |
| 77 | if (eq <= 0) throw new Error(`Invalid file command format: ${line}`); |
| 78 | out.push([line.slice(0, eq), line.slice(eq + 1)]); |
| 79 | } |
| 80 | return out; |
| 81 | } |
| 82 | |
| 83 | /** GITHUB_PATH: one directory per line. */ |
| 84 | export function parsePathFile(content: string): string[] { |
| 85 | return content |
| 86 | .split(/\r?\n/) |
| 87 | .map((l) => l.trim()) |
| 88 | .filter(Boolean); |
| 89 | } |