mandrel-platform 0.18.0 → 0.19.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/README.md +34 -20
- package/config/edge-security/rate-limit.mjs +103 -20
- package/default.json +4 -19
- package/package.json +1 -1
- package/scripts/audit-check.mjs +321 -180
- package/scripts/audit-check.test.mjs +263 -0
- package/scripts/check-action-pins.mjs +106 -173
- package/scripts/check-coverage-threshold.mjs +44 -6
- package/scripts/check-coverage-threshold.test.mjs +43 -0
- package/scripts/check-docs-staleness.mjs +130 -81
- package/scripts/check-docs-staleness.test.mjs +130 -0
- package/scripts/check-pin-drift.mjs +61 -110
- package/scripts/check-pin-drift.test.mjs +175 -3
- package/scripts/check-workflow-portability.mjs +163 -118
- package/scripts/check-workflow-portability.test.mjs +199 -0
- package/scripts/edge-security.test.mjs +81 -1
- package/scripts/lib/args.mjs +93 -0
- package/scripts/lib/args.test.mjs +152 -0
- package/scripts/lib/gh-json.mjs +119 -0
- package/scripts/lib/semver-duration.mjs +84 -0
- package/scripts/lib/uses-pins.mjs +220 -0
- package/scripts/lib/uses-pins.test.mjs +219 -0
- package/scripts/lib/walk.mjs +74 -0
- package/scripts/platform-repair.mjs +9 -3
- package/scripts/update-semgrep-rules.mjs +76 -5
- package/templates/runbooks/README.md +9 -5
- package/templates/runbooks/branch-protection-setup.md +9 -3
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/args.mjs
|
|
3
|
+
*
|
|
4
|
+
* The single argv-parsing seam shared by the pin-tooling CLIs
|
|
5
|
+
* (`check-action-pins.mjs`, `check-workflow-portability.mjs`, …). Each of
|
|
6
|
+
* those scripts had grown its own hand-rolled `parseArgs` — one throwing on
|
|
7
|
+
* an unknown flag, one silently ignoring it, and both re-implementing the
|
|
8
|
+
* same "take the next argv slot as this flag's value" dance. They had already
|
|
9
|
+
* drifted (different alias support, different unknown-flag policy), which is
|
|
10
|
+
* exactly the duplication Story #203 consolidates.
|
|
11
|
+
*
|
|
12
|
+
* `parseFlags(argv, spec)` is a tiny, dependency-free flag reader driven by a
|
|
13
|
+
* declarative spec. It intentionally does NOT try to be a full getopt: it
|
|
14
|
+
* supports the two shapes the pin tooling actually uses —
|
|
15
|
+
*
|
|
16
|
+
* • `string` flags — `--workflows-dir <value>` (optionally with aliases,
|
|
17
|
+
* e.g. `-w`), consuming the next argv slot as the value.
|
|
18
|
+
* • `boolean` flags — `--no-pin-check`, `--help` (present ⇒ the configured
|
|
19
|
+
* boolean value, default the inverse).
|
|
20
|
+
*
|
|
21
|
+
* The unknown-flag policy is a per-call knob (`onUnknown`) so a strict lint
|
|
22
|
+
* (fail loudly on a typo'd flag) and a lenient CLI (ignore stray args) can
|
|
23
|
+
* share one parser without either losing its behavior.
|
|
24
|
+
*
|
|
25
|
+
* This module reads no environment and performs no I/O, so the sibling
|
|
26
|
+
* `args.test.mjs` suite exercises it entirely offline.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} FlagSpec
|
|
31
|
+
* @property {"string" | "boolean"} type How to consume the flag.
|
|
32
|
+
* @property {string} dest The result key to write.
|
|
33
|
+
* @property {*} [default] Default value when the flag is absent.
|
|
34
|
+
* @property {boolean} [value] For a boolean flag, the value to set
|
|
35
|
+
* when the flag IS present (default true).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse an argv slice (the array AFTER `node script.mjs`) into an options
|
|
40
|
+
* object driven by `spec`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} argv
|
|
43
|
+
* @param {Object} spec
|
|
44
|
+
* @param {Record<string, FlagSpec>} spec.flags Map of canonical flag token
|
|
45
|
+
* (e.g. `"--workflows-dir"`) to its {@link FlagSpec}.
|
|
46
|
+
* @param {Record<string, string>} [spec.aliases] Map of alias token
|
|
47
|
+
* (e.g. `"-w"`) to a canonical flag token present in `spec.flags`.
|
|
48
|
+
* @param {"throw" | "ignore"} [spec.onUnknown] What to do with an argument
|
|
49
|
+
* that is not a known flag or alias. `"throw"` (default) fails loudly;
|
|
50
|
+
* `"ignore"` skips it.
|
|
51
|
+
* @returns {Record<string, *>} The resolved options, seeded from each flag's
|
|
52
|
+
* `default`.
|
|
53
|
+
*/
|
|
54
|
+
export function parseFlags(argv, spec) {
|
|
55
|
+
const flags = spec?.flags ?? {};
|
|
56
|
+
const aliases = spec?.aliases ?? {};
|
|
57
|
+
const onUnknown = spec?.onUnknown ?? "throw";
|
|
58
|
+
|
|
59
|
+
// Seed the result with every flag's declared default.
|
|
60
|
+
const opts = {};
|
|
61
|
+
for (const def of Object.values(flags)) {
|
|
62
|
+
opts[def.dest] = "default" in def ? def.default : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const canonical = (arg) => {
|
|
66
|
+
if (Object.prototype.hasOwnProperty.call(flags, arg)) return arg;
|
|
67
|
+
if (Object.prototype.hasOwnProperty.call(aliases, arg)) return aliases[arg];
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < argv.length; i++) {
|
|
72
|
+
const arg = argv[i];
|
|
73
|
+
const key = canonical(arg);
|
|
74
|
+
if (key === null) {
|
|
75
|
+
if (onUnknown === "ignore") continue;
|
|
76
|
+
throw new Error(`unknown argument "${arg}"`);
|
|
77
|
+
}
|
|
78
|
+
const def = flags[key];
|
|
79
|
+
if (def.type === "boolean") {
|
|
80
|
+
opts[def.dest] = "value" in def ? def.value : true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// string flag: consume the next argv slot as the value.
|
|
84
|
+
const next = argv[i + 1];
|
|
85
|
+
if (next === undefined || next.startsWith("--")) {
|
|
86
|
+
throw new Error(`missing value for "${arg}"`);
|
|
87
|
+
}
|
|
88
|
+
opts[def.dest] = next;
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return opts;
|
|
93
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* args.test.mjs — node:test suite for the shared argv parser
|
|
4
|
+
* (`scripts/lib/args.mjs`, Story #203).
|
|
5
|
+
*
|
|
6
|
+
* Covers the two flag shapes the pin tooling uses (string-with-value and
|
|
7
|
+
* boolean-present), alias resolution, default seeding, and both unknown-flag
|
|
8
|
+
* policies (throw for the strict ratchet, ignore for the lenient portability
|
|
9
|
+
* CLI). Pure — no I/O, fully offline.
|
|
10
|
+
*
|
|
11
|
+
* Run: node --test scripts/lib/args.test.mjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
|
|
17
|
+
import { parseFlags } from "./args.mjs";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Defaults
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
test("parseFlags seeds each flag's declared default when absent", () => {
|
|
24
|
+
const opts = parseFlags([], {
|
|
25
|
+
flags: {
|
|
26
|
+
"--dir": { type: "string", dest: "dir", default: ".github/workflows" },
|
|
27
|
+
"--flag": { type: "boolean", dest: "flag", default: false },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
assert.deepEqual(opts, { dir: ".github/workflows", flag: false });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("parseFlags leaves dest undefined when a flag has no default", () => {
|
|
34
|
+
const opts = parseFlags([], {
|
|
35
|
+
flags: { "--dir": { type: "string", dest: "dir" } },
|
|
36
|
+
});
|
|
37
|
+
assert.equal("dir" in opts, true);
|
|
38
|
+
assert.equal(opts.dir, undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// String flags
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
test("parseFlags reads a string flag's value from the next slot", () => {
|
|
46
|
+
const opts = parseFlags(["--dir", "wf"], {
|
|
47
|
+
flags: { "--dir": { type: "string", dest: "dir", default: null } },
|
|
48
|
+
});
|
|
49
|
+
assert.equal(opts.dir, "wf");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("parseFlags throws when a string flag is missing its value", () => {
|
|
53
|
+
assert.throws(
|
|
54
|
+
() =>
|
|
55
|
+
parseFlags(["--dir"], {
|
|
56
|
+
flags: { "--dir": { type: "string", dest: "dir" } },
|
|
57
|
+
}),
|
|
58
|
+
/missing value for "--dir"/
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("parseFlags treats a following --flag as a missing value, not the value", () => {
|
|
63
|
+
assert.throws(
|
|
64
|
+
() =>
|
|
65
|
+
parseFlags(["--dir", "--other"], {
|
|
66
|
+
flags: {
|
|
67
|
+
"--dir": { type: "string", dest: "dir" },
|
|
68
|
+
"--other": { type: "boolean", dest: "other" },
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
/missing value for "--dir"/
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Boolean flags
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
test("parseFlags sets a boolean flag to its configured value when present", () => {
|
|
80
|
+
const opts = parseFlags(["--no-pin-check"], {
|
|
81
|
+
flags: {
|
|
82
|
+
"--no-pin-check": { type: "boolean", dest: "pinCheck", value: false, default: true },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
assert.equal(opts.pinCheck, false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("parseFlags defaults a boolean present-value to true", () => {
|
|
89
|
+
const opts = parseFlags(["--help"], {
|
|
90
|
+
flags: { "--help": { type: "boolean", dest: "help", default: false } },
|
|
91
|
+
});
|
|
92
|
+
assert.equal(opts.help, true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Aliases
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
test("parseFlags resolves an alias to its canonical flag", () => {
|
|
100
|
+
const opts = parseFlags(["-w", "wf", "-h"], {
|
|
101
|
+
flags: {
|
|
102
|
+
"--workflows-dir": { type: "string", dest: "workflowsDir", default: null },
|
|
103
|
+
"--help": { type: "boolean", dest: "help", default: false },
|
|
104
|
+
},
|
|
105
|
+
aliases: { "-w": "--workflows-dir", "-h": "--help" },
|
|
106
|
+
});
|
|
107
|
+
assert.equal(opts.workflowsDir, "wf");
|
|
108
|
+
assert.equal(opts.help, true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Unknown-flag policy
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
test("parseFlags throws on an unknown flag by default", () => {
|
|
116
|
+
assert.throws(
|
|
117
|
+
() => parseFlags(["--nope"], { flags: {} }),
|
|
118
|
+
/unknown argument "--nope"/
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("parseFlags ignores unknown args when onUnknown is 'ignore'", () => {
|
|
123
|
+
const opts = parseFlags(["--nope", "--dir", "wf", "stray"], {
|
|
124
|
+
flags: { "--dir": { type: "string", dest: "dir", default: null } },
|
|
125
|
+
onUnknown: "ignore",
|
|
126
|
+
});
|
|
127
|
+
assert.equal(opts.dir, "wf");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Realistic combined spec (mirrors check-action-pins.mjs)
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
test("parseFlags handles a full mixed spec end-to-end", () => {
|
|
135
|
+
const spec = {
|
|
136
|
+
flags: {
|
|
137
|
+
"--workflows-dir": { type: "string", dest: "workflowsDir", default: ".github/workflows" },
|
|
138
|
+
"--actions-dir": { type: "string", dest: "actionsDir", default: ".github/actions" },
|
|
139
|
+
"--first-party-owner": { type: "string", dest: "firstPartyOwner", default: "dsj1984/mandrel-platform" },
|
|
140
|
+
"--no-single-pin": { type: "boolean", dest: "singlePin", value: false, default: true },
|
|
141
|
+
},
|
|
142
|
+
onUnknown: "throw",
|
|
143
|
+
};
|
|
144
|
+
const opts = parseFlags(
|
|
145
|
+
["--first-party-owner", "x/y", "--workflows-dir", "wf", "--no-single-pin"],
|
|
146
|
+
spec
|
|
147
|
+
);
|
|
148
|
+
assert.equal(opts.firstPartyOwner, "x/y");
|
|
149
|
+
assert.equal(opts.workflowsDir, "wf");
|
|
150
|
+
assert.equal(opts.actionsDir, ".github/actions");
|
|
151
|
+
assert.equal(opts.singlePin, false);
|
|
152
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/gh-json.mjs
|
|
3
|
+
*
|
|
4
|
+
* The single GitHub-access seam shared by the pin-drift dashboard
|
|
5
|
+
* (`check-pin-drift.mjs`) and the pin-repair loop (`platform-repair.mjs`).
|
|
6
|
+
* Extracted from `check-pin-drift.mjs` (Story #198) so both consumers run
|
|
7
|
+
* `gh api` through one thin, injectable runner and one JSON parser.
|
|
8
|
+
*
|
|
9
|
+
* GitHub access is via the `gh` CLI (`gh api`), so callers inherit the
|
|
10
|
+
* environment's auth (a `GH_TOKEN`/`GITHUB_TOKEN` in CI, or `gh auth`
|
|
11
|
+
* locally). No secrets are read or printed here.
|
|
12
|
+
*
|
|
13
|
+
* ## Fail-closed HTTP-status surfacing (Story #198)
|
|
14
|
+
*
|
|
15
|
+
* `gh api` exits non-zero on any HTTP error and writes a line like
|
|
16
|
+
* `gh: Not Found (HTTP 404)` to stderr. `execFileSync` turns that into a
|
|
17
|
+
* thrown Error whose `.stderr` / `.message` carries the `(HTTP <code>)`
|
|
18
|
+
* marker. Historically the per-consumer fetchers in the dashboard caught
|
|
19
|
+
* *every* such error and returned an "absent" sentinel — so a transient 500,
|
|
20
|
+
* a network blip, or an auth failure all read as "no drift" and the
|
|
21
|
+
* `--strict` gate silently exited 0 (a fail-OPEN). That is the bug this
|
|
22
|
+
* module exists to close.
|
|
23
|
+
*
|
|
24
|
+
* `httpStatusOf(err)` parses the HTTP status back out of a thrown `gh` error,
|
|
25
|
+
* and `isNotFound(err)` is the ONLY predicate a fetcher may use to justify
|
|
26
|
+
* swallowing an error into an "absent" result. Every other error must
|
|
27
|
+
* propagate so the caller's per-consumer catch records an `error` row (which
|
|
28
|
+
* `hasDrift` counts and `--strict` fails on) — the gate fails CLOSED.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default gh runner — shells out to the `gh` CLI.
|
|
35
|
+
*
|
|
36
|
+
* @param {string[]} args
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function defaultGhRunner(args) {
|
|
40
|
+
return execFileSync("gh", args, {
|
|
41
|
+
encoding: "utf-8",
|
|
42
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract the HTTP status code from an error thrown by a `gh api` invocation.
|
|
48
|
+
* `gh` reports failures as `gh: <message> (HTTP <code>)` on stderr, which
|
|
49
|
+
* `execFileSync` surfaces on the thrown error's `.stderr` (a Buffer/string)
|
|
50
|
+
* and, for some shells, folded into `.message`. Also handles a structured
|
|
51
|
+
* error that already carries a numeric `.httpStatus` / `.status` HTTP field.
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} err
|
|
54
|
+
* @returns {number | null} The HTTP status, or null when none can be parsed.
|
|
55
|
+
*/
|
|
56
|
+
export function httpStatusOf(err) {
|
|
57
|
+
if (!err || typeof err !== "object") return null;
|
|
58
|
+
// A pre-tagged HTTP status wins (set by ghApiJson on rethrow, or by a caller
|
|
59
|
+
// that already classified the error). Guard against `execFileSync`'s own
|
|
60
|
+
// numeric `.status` (that is a PROCESS exit code, not an HTTP status), so we
|
|
61
|
+
// only trust an explicit `.httpStatus`.
|
|
62
|
+
const tagged = /** @type {{ httpStatus?: unknown }} */ (err).httpStatus;
|
|
63
|
+
if (typeof tagged === "number" && Number.isInteger(tagged)) return tagged;
|
|
64
|
+
|
|
65
|
+
const parts = [];
|
|
66
|
+
const e = /** @type {{ stderr?: unknown, stdout?: unknown, message?: unknown }} */ (err);
|
|
67
|
+
if (e.stderr != null) parts.push(String(e.stderr));
|
|
68
|
+
if (e.stdout != null) parts.push(String(e.stdout));
|
|
69
|
+
if (typeof e.message === "string") parts.push(e.message);
|
|
70
|
+
const haystack = parts.join("\n");
|
|
71
|
+
// `gh` form: "(HTTP 404)". REST body form: "\"status\":\"404\"".
|
|
72
|
+
const m = /\(HTTP\s+(\d{3})\)/.exec(haystack) || /"status"\s*:\s*"(\d{3})"/.exec(haystack);
|
|
73
|
+
return m ? Number.parseInt(m[1], 10) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Is `err` a GitHub 404 (Not Found)? This is the ONLY error class a fetcher
|
|
78
|
+
* may legitimately swallow into an "absent" sentinel — a 404 genuinely means
|
|
79
|
+
* "this resource does not exist" (a consumer with no `package.json`, no
|
|
80
|
+
* `.github/workflows/` dir, no release). Every other status (403, 429, 5xx,
|
|
81
|
+
* or an unparseable transport failure) must fail CLOSED.
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} err
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
export function isNotFound(err) {
|
|
87
|
+
return httpStatusOf(err) === 404;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run `gh api <path>` and parse the JSON response. On failure, the thrown
|
|
92
|
+
* error is re-thrown with its HTTP status tagged on `.httpStatus` (parsed via
|
|
93
|
+
* `httpStatusOf`) so downstream `catch` blocks can distinguish a 404 (safe to
|
|
94
|
+
* treat as "absent") from every other error (must fail closed). The status is
|
|
95
|
+
* surfaced, never swallowed here — the swallow/rethrow policy lives in the
|
|
96
|
+
* per-consumer fetchers.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} apiPath e.g. "repos/owner/repo/releases/latest".
|
|
99
|
+
* @param {(args: string[]) => string} runGh Injectable runner.
|
|
100
|
+
* @returns {unknown}
|
|
101
|
+
*/
|
|
102
|
+
export function ghApiJson(apiPath, runGh) {
|
|
103
|
+
let raw;
|
|
104
|
+
try {
|
|
105
|
+
raw = runGh(["api", apiPath, "-H", "Accept: application/vnd.github+json"]);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const status = httpStatusOf(err);
|
|
108
|
+
if (status !== null && err && typeof err === "object" && !("httpStatus" in err)) {
|
|
109
|
+
try {
|
|
110
|
+
/** @type {{ httpStatus?: number }} */ (err).httpStatus = status;
|
|
111
|
+
} catch {
|
|
112
|
+
// Non-extensible error object — the parseable status still lives in
|
|
113
|
+
// `.stderr`/`.message`, so httpStatusOf(err) recovers it downstream.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
return JSON.parse(raw);
|
|
119
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/semver-duration.mjs
|
|
3
|
+
*
|
|
4
|
+
* The semver + Renovate-duration parsers shared by the pin-drift dashboard
|
|
5
|
+
* (`check-pin-drift.mjs`) and the pin-repair loop (`platform-repair.mjs`).
|
|
6
|
+
* Extracted from `check-pin-drift.mjs` (Story #198) so both consumers parse
|
|
7
|
+
* release tags, dependency specs, and `minimumReleaseAge` windows through one
|
|
8
|
+
* SSOT rather than a monolith import.
|
|
9
|
+
*
|
|
10
|
+
* These are pure functions — no I/O, no GitHub access.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract a comparable `x.y.z` semver core from a release tag or version spec.
|
|
15
|
+
* The platform tags releases as `mandrel-platform-v<semver>`; consumer specs
|
|
16
|
+
* may carry a range prefix (`^0.11.3`, `~0.11.3`). Returns the dotted triple
|
|
17
|
+
* or null when no numeric semver core is present (`workspace:*`, `latest`, a
|
|
18
|
+
* git URL).
|
|
19
|
+
*
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
* @returns {string | null}
|
|
22
|
+
*/
|
|
23
|
+
export function parseSemver(value) {
|
|
24
|
+
if (typeof value !== "string") return null;
|
|
25
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(value);
|
|
26
|
+
return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compare two `x.y.z` semver cores. Returns -1 when a < b, 0 when equal, 1
|
|
31
|
+
* when a > b. Inputs MUST already be normalized dotted triples (see
|
|
32
|
+
* `parseSemver`).
|
|
33
|
+
*
|
|
34
|
+
* @param {string} a
|
|
35
|
+
* @param {string} b
|
|
36
|
+
* @returns {-1 | 0 | 1}
|
|
37
|
+
*/
|
|
38
|
+
export function compareSemver(a, b) {
|
|
39
|
+
const pa = a.split(".").map(Number);
|
|
40
|
+
const pb = b.split(".").map(Number);
|
|
41
|
+
for (let i = 0; i < 3; i += 1) {
|
|
42
|
+
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a Renovate-style `minimumReleaseAge` duration into milliseconds. The
|
|
49
|
+
* preset uses human strings like `"3 days"`, `"36 hours"`, `"1 week"`; this
|
|
50
|
+
* accepts an integer (or float) count followed by a unit (the same units
|
|
51
|
+
* Renovate's `ms`-backed parser accepts). Returns null for an unparseable or
|
|
52
|
+
* non-positive value so the caller can fall back to "no hold window".
|
|
53
|
+
*
|
|
54
|
+
* @param {unknown} value
|
|
55
|
+
* @returns {number | null} Window length in ms, or null.
|
|
56
|
+
*/
|
|
57
|
+
export function parseDurationMs(value) {
|
|
58
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
59
|
+
// Bare number is interpreted as days (the preset's unit of record).
|
|
60
|
+
return value * 24 * 60 * 60 * 1000;
|
|
61
|
+
}
|
|
62
|
+
if (typeof value !== "string") return null;
|
|
63
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*([a-z]+)\s*$/i.exec(value.trim());
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
const count = Number.parseFloat(m[1]);
|
|
66
|
+
if (!Number.isFinite(count) || count <= 0) return null;
|
|
67
|
+
const unit = m[2].toLowerCase();
|
|
68
|
+
const units = {
|
|
69
|
+
minute: 60 * 1000,
|
|
70
|
+
minutes: 60 * 1000,
|
|
71
|
+
min: 60 * 1000,
|
|
72
|
+
mins: 60 * 1000,
|
|
73
|
+
hour: 60 * 60 * 1000,
|
|
74
|
+
hours: 60 * 60 * 1000,
|
|
75
|
+
hr: 60 * 60 * 1000,
|
|
76
|
+
hrs: 60 * 60 * 1000,
|
|
77
|
+
day: 24 * 60 * 60 * 1000,
|
|
78
|
+
days: 24 * 60 * 60 * 1000,
|
|
79
|
+
week: 7 * 24 * 60 * 60 * 1000,
|
|
80
|
+
weeks: 7 * 24 * 60 * 60 * 1000,
|
|
81
|
+
};
|
|
82
|
+
const factor = units[unit];
|
|
83
|
+
return factor ? count * factor : null;
|
|
84
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/uses-pins.mjs
|
|
3
|
+
*
|
|
4
|
+
* The single home for `uses:`-line parsing, reference classification, and
|
|
5
|
+
* 40-hex-SHA validation shared by the pin-tooling scripts. These primitives
|
|
6
|
+
* had been copy-pasted into `check-action-pins.mjs` (the SHA-pin ratchet) and
|
|
7
|
+
* partially re-implemented as ad-hoc regexes in `check-workflow-portability.mjs`
|
|
8
|
+
* (the internal-pin collector) — the exact drift Story #203 consolidates.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here reads the filesystem or the environment: every function is a
|
|
11
|
+
* pure text transform, so the sibling `uses-pins.test.mjs` runs fully offline.
|
|
12
|
+
*
|
|
13
|
+
* ## Vocabulary
|
|
14
|
+
*
|
|
15
|
+
* • A raw `uses:` VALUE is whatever follows `uses:` on a workflow/action
|
|
16
|
+
* line, possibly quoted and possibly carrying a trailing `# vX.Y.Z` tag
|
|
17
|
+
* note. {@link stripUsesValue} reduces it to the bare reference.
|
|
18
|
+
* • A bare REFERENCE is `owner/repo[/subpath]@gitref`, `./local/path`, or
|
|
19
|
+
* `docker://image`. {@link classifyUses} sorts it into first-party /
|
|
20
|
+
* third-party / local / docker / unparseable.
|
|
21
|
+
* • A git REF is a full 40-char commit SHA, a tag, a branch, or a short SHA.
|
|
22
|
+
* {@link isSha40} is the SHA-pin predicate the ratchet enforces.
|
|
23
|
+
*
|
|
24
|
+
* ## Single-pin invariant (Story #203)
|
|
25
|
+
*
|
|
26
|
+
* {@link findSinglePinViolations} adds an INTRA-repo check absent from the
|
|
27
|
+
* cross-repo pin-drift dashboard: within one repo's `.github/workflows/`, two
|
|
28
|
+
* first-party `uses:` refs to the SAME subpath MUST carry the SAME SHA. Two
|
|
29
|
+
* workflows pinning `owner/repo/.github/actions/foo` at different commits is a
|
|
30
|
+
* silent split-brain — one workflow runs the fixed action, the other the
|
|
31
|
+
* stale one. This check makes that state fail CI.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const DEFAULT_FIRST_PARTY_OWNER = "dsj1984/mandrel-platform";
|
|
35
|
+
|
|
36
|
+
/** A full git commit SHA is exactly 40 lowercase/uppercase hex characters. */
|
|
37
|
+
const SHA40_RE = /^[0-9a-fA-F]{40}$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Matches a YAML `uses:` mapping key: optional leading whitespace, an optional
|
|
41
|
+
* leading `- ` (sequence item), then `uses:` and the value. A `uses:` inside a
|
|
42
|
+
* `#` comment or a `run:` heredoc is indented past a leading `#`, so anchoring
|
|
43
|
+
* on the leading token avoids false hits on documentation examples.
|
|
44
|
+
*/
|
|
45
|
+
const USES_LINE_RE = /^\s*(?:-\s+)?uses:\s*(\S.*)$/;
|
|
46
|
+
|
|
47
|
+
export { DEFAULT_FIRST_PARTY_OWNER, SHA40_RE, USES_LINE_RE };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strip a trailing `# comment` (the conventional `# v4.2.2` tag note) and
|
|
51
|
+
* surrounding whitespace/quotes from a raw `uses:` value, returning the bare
|
|
52
|
+
* action reference. A `#` inside the ref itself is not valid GitHub syntax,
|
|
53
|
+
* so splitting on the first ` #` is safe.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} raw
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function stripUsesValue(raw) {
|
|
59
|
+
let v = String(raw).trim();
|
|
60
|
+
// Drop a trailing comment: the first '#' that is preceded by whitespace (or
|
|
61
|
+
// at the start) begins a comment. GitHub action refs never contain '#'.
|
|
62
|
+
const hashIdx = v.search(/\s#/);
|
|
63
|
+
if (hashIdx !== -1) v = v.slice(0, hashIdx);
|
|
64
|
+
v = v.trim();
|
|
65
|
+
// Unwrap matched surrounding quotes.
|
|
66
|
+
if (
|
|
67
|
+
(v.startsWith('"') && v.endsWith('"')) ||
|
|
68
|
+
(v.startsWith("'") && v.endsWith("'"))
|
|
69
|
+
) {
|
|
70
|
+
v = v.slice(1, -1).trim();
|
|
71
|
+
}
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* If a line is a YAML `uses:` mapping key, return its bare reference (comment
|
|
77
|
+
* and quotes stripped); otherwise return null. Whole-line `#` comments never
|
|
78
|
+
* match.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} line
|
|
81
|
+
* @returns {string | null}
|
|
82
|
+
*/
|
|
83
|
+
export function parseUsesLine(line) {
|
|
84
|
+
const raw = String(line);
|
|
85
|
+
if (/^\s*#/.test(raw)) return null;
|
|
86
|
+
const m = raw.match(USES_LINE_RE);
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
return stripUsesValue(m[1]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Classify a bare `uses:` reference. Returns one of:
|
|
93
|
+
* { kind: 'local' } — `./path` or `../path` (exempt)
|
|
94
|
+
* { kind: 'docker' } — `docker://image` (exempt)
|
|
95
|
+
* { kind: 'first-party', owner, subpath, ref } — the configured first-party
|
|
96
|
+
* owner (exempt from the SHA ratchet)
|
|
97
|
+
* { kind: 'third-party', owner, subpath, ref } — external action (MUST be
|
|
98
|
+
* SHA-pinned)
|
|
99
|
+
* { kind: 'unparseable' } — not a recognizable `uses:` reference
|
|
100
|
+
*
|
|
101
|
+
* `subpath` is the path segment after `owner/repo/` (empty string when the ref
|
|
102
|
+
* is the bare `owner/repo`), so single-pin comparison can key on it.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} bareRef
|
|
105
|
+
* @param {string} [firstPartyOwner]
|
|
106
|
+
* @returns {{kind: string, owner?: string, subpath?: string, ref?: string, ownerRepoPath?: string}}
|
|
107
|
+
*/
|
|
108
|
+
export function classifyUses(bareRef, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
|
|
109
|
+
const ref = String(bareRef).trim();
|
|
110
|
+
if (ref === "") return { kind: "unparseable" };
|
|
111
|
+
if (ref.startsWith("./") || ref.startsWith("../")) return { kind: "local" };
|
|
112
|
+
if (ref.startsWith("docker://")) return { kind: "docker" };
|
|
113
|
+
|
|
114
|
+
// owner/repo[/subpath]@gitref. The git ref is everything after the LAST '@'
|
|
115
|
+
// (an action subpath never contains '@'; the ref does not either).
|
|
116
|
+
const atIdx = ref.lastIndexOf("@");
|
|
117
|
+
if (atIdx === -1) {
|
|
118
|
+
// No `@ref` at all — not a pinnable external reference (e.g. a malformed
|
|
119
|
+
// entry). Treat as unparseable so the caller can flag it explicitly.
|
|
120
|
+
return { kind: "unparseable", ownerRepoPath: ref };
|
|
121
|
+
}
|
|
122
|
+
const ownerRepoPath = ref.slice(0, atIdx);
|
|
123
|
+
const gitRef = ref.slice(atIdx + 1);
|
|
124
|
+
const segments = ownerRepoPath.split("/");
|
|
125
|
+
if (segments.length < 2) return { kind: "unparseable", ownerRepoPath, ref: gitRef };
|
|
126
|
+
|
|
127
|
+
const ownerRepo = `${segments[0]}/${segments[1]}`;
|
|
128
|
+
const subpath = segments.slice(2).join("/");
|
|
129
|
+
if (ownerRepo.toLowerCase() === String(firstPartyOwner).toLowerCase()) {
|
|
130
|
+
return { kind: "first-party", owner: ownerRepo, subpath, ref: gitRef };
|
|
131
|
+
}
|
|
132
|
+
return { kind: "third-party", owner: ownerRepo, subpath, ref: gitRef };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* True when a git ref is a full 40-character commit SHA.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} gitRef
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function isSha40(gitRef) {
|
|
142
|
+
return SHA40_RE.test(String(gitRef).trim());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Scan a file's TEXT for first-party `uses:` refs and index each by its
|
|
147
|
+
* `owner/repo/subpath` reference target, capturing the SHA (or non-SHA ref)
|
|
148
|
+
* each site pins. Returns a Map keyed by `owner/repo` + `/subpath` (the full
|
|
149
|
+
* reference minus `@ref`), each value an array of
|
|
150
|
+
* `{ file, line, ref, target }` occurrences. Only `first-party` refs with a
|
|
151
|
+
* non-empty subpath are collected — a bare `owner/repo@ref` self-reference has
|
|
152
|
+
* no subpath to disambiguate, and third-party refs are governed by the
|
|
153
|
+
* cross-repo pin-drift dashboard, not the intra-repo single-pin invariant.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} content
|
|
156
|
+
* @param {string} displayFile
|
|
157
|
+
* @param {string} [firstPartyOwner]
|
|
158
|
+
* @returns {Map<string, Array<{file: string, line: number, ref: string, target: string}>>}
|
|
159
|
+
*/
|
|
160
|
+
export function collectFirstPartyPins(
|
|
161
|
+
content,
|
|
162
|
+
displayFile,
|
|
163
|
+
firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER
|
|
164
|
+
) {
|
|
165
|
+
const byTarget = new Map();
|
|
166
|
+
const lines = String(content).split(/\r?\n/);
|
|
167
|
+
for (let i = 0; i < lines.length; i++) {
|
|
168
|
+
const bareRef = parseUsesLine(lines[i]);
|
|
169
|
+
if (bareRef === null) continue;
|
|
170
|
+
const cls = classifyUses(bareRef, firstPartyOwner);
|
|
171
|
+
if (cls.kind !== "first-party") continue;
|
|
172
|
+
if (!cls.subpath) continue; // bare owner/repo self-ref has no subpath to key on
|
|
173
|
+
const target = `${cls.owner}/${cls.subpath}`;
|
|
174
|
+
const occ = { file: displayFile, line: i + 1, ref: cls.ref, target };
|
|
175
|
+
const existing = byTarget.get(target);
|
|
176
|
+
if (existing) existing.push(occ);
|
|
177
|
+
else byTarget.set(target, [occ]);
|
|
178
|
+
}
|
|
179
|
+
return byTarget;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The single-pin invariant (Story #203). Given a list of `{ file, content }`
|
|
184
|
+
* records (the repo's workflow files), find every first-party `uses:` target
|
|
185
|
+
* that is pinned to MORE THAN ONE distinct SHA across the set. Returns an
|
|
186
|
+
* array of violations, one per drifting target:
|
|
187
|
+
*
|
|
188
|
+
* { target, shas: [...distinct refs], occurrences: [{ file, line, ref }] }
|
|
189
|
+
*
|
|
190
|
+
* A target pinned consistently (or referenced only once) yields no violation.
|
|
191
|
+
*
|
|
192
|
+
* @param {Array<{file: string, content: string}>} files
|
|
193
|
+
* @param {string} [firstPartyOwner]
|
|
194
|
+
* @returns {Array<{target: string, shas: string[], occurrences: Array<{file: string, line: number, ref: string}>}>}
|
|
195
|
+
*/
|
|
196
|
+
export function findSinglePinViolations(files, firstPartyOwner = DEFAULT_FIRST_PARTY_OWNER) {
|
|
197
|
+
// Merge every file's per-target occurrences into one index.
|
|
198
|
+
const merged = new Map();
|
|
199
|
+
for (const { file, content } of files) {
|
|
200
|
+
const perFile = collectFirstPartyPins(content, file, firstPartyOwner);
|
|
201
|
+
for (const [target, occs] of perFile) {
|
|
202
|
+
const existing = merged.get(target);
|
|
203
|
+
if (existing) existing.push(...occs);
|
|
204
|
+
else merged.set(target, [...occs]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const violations = [];
|
|
209
|
+
for (const [target, occs] of merged) {
|
|
210
|
+
const distinct = [...new Set(occs.map((o) => o.ref))];
|
|
211
|
+
if (distinct.length > 1) {
|
|
212
|
+
violations.push({
|
|
213
|
+
target,
|
|
214
|
+
shas: distinct,
|
|
215
|
+
occurrences: occs.map((o) => ({ file: o.file, line: o.line, ref: o.ref })),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return violations;
|
|
220
|
+
}
|