| 1 | // What a job looks like from inside, by shape, for comparing a run on |
| 2 | // GitHub with the same run on g1t (scripts/conformance-diff.mjs). Values |
| 3 | // that differ between hosts by nature (ids, times, hosts, paths) are kept |
| 4 | // as their shape: which variables are set, which keys the event and the |
| 5 | // contexts have and of what JSON type, and how paths sit relative to one |
| 6 | // another. Run by .g1t/workflows/conformance.yml and its copy in |
| 7 | // .github/workflows/; see CONTRIBUTING.md. |
| 8 | // |
| 9 | // node conformance/report.mjs <out.json> |
| 10 | // with CTX_<NAME> set to toJSON of each context to compare by shape |
| 11 | // (github, runner, job, strategy, matrix, steps, needs), and CHECK_<name> |
| 12 | // to each value the steps and expressions should give alike everywhere. |
| 13 | |
| 14 | import { readFileSync, writeFileSync } from "node:fs"; |
| 15 | import { relative } from "node:path"; |
| 16 | |
| 17 | /** A JSON value's shape: every key path with its type, arrays by their first item. */ |
| 18 | export function shape(value, prefix = "", into = {}) { |
| 19 | const type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value; |
| 20 | if (prefix) into[prefix] = type; |
| 21 | if (type === "object") { |
| 22 | for (const [key, inner] of Object.entries(value)) shape(inner, prefix ? `${prefix}.${key}` : key, into); |
| 23 | } else if (type === "array" && value.length > 0) { |
| 24 | shape(value[0], `${prefix}[]`, into); |
| 25 | } |
| 26 | return into; |
| 27 | } |
| 28 | |
| 29 | /** What kind of text a variable holds, without its value. */ |
| 30 | export function kind(text) { |
| 31 | if (text === "") return "empty"; |
| 32 | if (/^(true|false)$/.test(text)) return "boolean"; |
| 33 | if (/^\d+$/.test(text)) return "integer"; |
| 34 | if (/^[0-9a-f]{40}$/.test(text)) return "sha"; |
| 35 | if (/^https?:\/\//.test(text)) return "url"; |
| 36 | if (/^(\/|[A-Za-z]:\\)/.test(text)) return "path"; |
| 37 | if (/^refs\//.test(text)) return "ref"; |
| 38 | return "text"; |
| 39 | } |
| 40 | |
| 41 | /** The variables GitHub's runner sets, by kind; others are left out. */ |
| 42 | export function variables(env) { |
| 43 | const out = {}; |
| 44 | for (const [name, value] of Object.entries(env)) { |
| 45 | if (/^(GITHUB_|RUNNER_|ACTIONS_)/.test(name) && !name.startsWith("CTX_") || ["CI", "ImageOS", "ImageVersion", "HOME"].includes(name)) { |
| 46 | out[name] = kind(value ?? ""); |
| 47 | } |
| 48 | } |
| 49 | return out; |
| 50 | } |
| 51 | |
| 52 | /** How the job's folders sit relative to one another. */ |
| 53 | export function layout(env) { |
| 54 | const at = (name) => env[name] ?? ""; |
| 55 | return { |
| 56 | workspace_under_runner_workspace: at("GITHUB_WORKSPACE") !== "" && !relative(at("RUNNER_WORKSPACE"), at("GITHUB_WORKSPACE")).startsWith(".."), |
| 57 | workspace_is_repo_twice: (() => { |
| 58 | const parts = at("GITHUB_WORKSPACE").split(/[\\/]/).filter(Boolean); |
| 59 | return parts.length >= 2 && parts[parts.length - 1] === parts[parts.length - 2]; |
| 60 | })(), |
| 61 | temp_under_runner: at("RUNNER_TEMP") !== "" && at("RUNNER_TEMP").includes("runner"), |
| 62 | event_file_exists: (() => { |
| 63 | try { |
| 64 | readFileSync(at("GITHUB_EVENT_PATH")); |
| 65 | return true; |
| 66 | } catch { |
| 67 | return false; |
| 68 | } |
| 69 | })(), |
| 70 | }; |
| 71 | } |
| 72 | |
| 73 | /** The variables starting with `prefix`, by the rest of their names, lowercased. */ |
| 74 | function prefixed(env, prefix) { |
| 75 | return Object.fromEntries( |
| 76 | Object.entries(env) |
| 77 | .filter(([name]) => name.startsWith(prefix)) |
| 78 | .map(([name, value]) => [name.slice(prefix.length).toLowerCase(), value ?? ""]), |
| 79 | ); |
| 80 | } |
| 81 | |
| 82 | export function report(env) { |
| 83 | const contexts = Object.fromEntries( |
| 84 | Object.entries(prefixed(env, "CTX_")).map(([name, text]) => { |
| 85 | try { |
| 86 | return [name, JSON.parse(text)]; |
| 87 | } catch { |
| 88 | return [name, text]; |
| 89 | } |
| 90 | }), |
| 91 | ); |
| 92 | const event = (() => { |
| 93 | try { |
| 94 | return JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8")); |
| 95 | } catch { |
| 96 | return null; |
| 97 | } |
| 98 | })(); |
| 99 | return { |
| 100 | variables: variables(env), |
| 101 | layout: layout(env), |
| 102 | contexts: Object.fromEntries(Object.entries(contexts).map(([name, value]) => [name, shape(value)])), |
| 103 | event: event ? shape(event) : null, |
| 104 | // What earlier steps saw, compared by value: these should be the same everywhere. |
| 105 | checks: prefixed(env, "CHECK_"), |
| 106 | }; |
| 107 | } |
| 108 | |
| 109 | if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { |
| 110 | const out = process.argv[2] ?? "conformance.json"; |
| 111 | writeFileSync(out, `${JSON.stringify(report(process.env), null, 2)}\n`); |
| 112 | console.log(`Wrote ${out}`); |
| 113 | } |