mandrel-platform 0.17.2 → 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 +254 -34
- package/config/commitlint.base.mjs +36 -0
- package/config/edge-security/rate-limit.mjs +103 -20
- package/config/repo-settings.schema.json +78 -0
- package/default.json +4 -19
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- 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-repo-settings.mjs +363 -0
- package/scripts/check-repo-settings.test.mjs +320 -0
- package/scripts/check-required-contexts.mjs +247 -129
- package/scripts/check-required-contexts.test.mjs +137 -0
- package/scripts/check-ruleset.mjs +435 -0
- package/scripts/check-ruleset.test.mjs +439 -0
- package/scripts/check-workflow-portability.mjs +163 -118
- package/scripts/check-workflow-portability.test.mjs +199 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -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/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/scripts/update-semgrep-rules.mjs +76 -5
- package/templates/runbooks/README.md +9 -5
- package/templates/runbooks/branch-protection-setup.md +9 -3
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* uses-pins.test.mjs — node:test suite for the shared `uses:`-line / SHA-pin
|
|
4
|
+
* primitives (`scripts/lib/uses-pins.mjs`, Story #203).
|
|
5
|
+
*
|
|
6
|
+
* Covers value stripping, line parsing, reference classification (incl. the
|
|
7
|
+
* new `subpath` field), the 40-char-SHA predicate, and the intra-repo
|
|
8
|
+
* single-pin invariant that `check-action-pins.mjs` now enforces. Pure — no
|
|
9
|
+
* temp dirs, no git, fully offline.
|
|
10
|
+
*
|
|
11
|
+
* Run: node --test scripts/lib/uses-pins.test.mjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_FIRST_PARTY_OWNER,
|
|
19
|
+
stripUsesValue,
|
|
20
|
+
parseUsesLine,
|
|
21
|
+
classifyUses,
|
|
22
|
+
isSha40,
|
|
23
|
+
collectFirstPartyPins,
|
|
24
|
+
findSinglePinViolations,
|
|
25
|
+
} from "./uses-pins.mjs";
|
|
26
|
+
|
|
27
|
+
const SHA = "11bd71901bbe5b1630ceea73d27597364c9af683"; // 40 hex
|
|
28
|
+
const SHA2 = "0000000000000000000000000000000000000000"; // 40 hex, distinct
|
|
29
|
+
const SHORT = "11bd719"; // 7 hex
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// isSha40
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
test("isSha40 accepts exactly 40 hex chars (any case)", () => {
|
|
36
|
+
assert.equal(isSha40(SHA), true);
|
|
37
|
+
assert.equal(isSha40(SHA.toUpperCase()), true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("isSha40 rejects tags, short SHAs, branches, and off-by-one", () => {
|
|
41
|
+
assert.equal(isSha40("v4"), false);
|
|
42
|
+
assert.equal(isSha40(SHORT), false);
|
|
43
|
+
assert.equal(isSha40("main"), false);
|
|
44
|
+
assert.equal(isSha40(SHA + "0"), false); // 41 chars
|
|
45
|
+
assert.equal(isSha40("g".repeat(40)), false); // non-hex
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// stripUsesValue
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
test("stripUsesValue drops the trailing # tag comment", () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
stripUsesValue(`actions/checkout@${SHA} # v4.2.2`),
|
|
55
|
+
`actions/checkout@${SHA}`
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("stripUsesValue unwraps surrounding quotes and no-comment inputs", () => {
|
|
60
|
+
assert.equal(stripUsesValue(`actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
|
|
61
|
+
assert.equal(stripUsesValue(`"actions/checkout@${SHA}"`), `actions/checkout@${SHA}`);
|
|
62
|
+
assert.equal(stripUsesValue(`'actions/checkout@${SHA}'`), `actions/checkout@${SHA}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// parseUsesLine
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
test("parseUsesLine returns the bare ref for a mapping-key uses line", () => {
|
|
70
|
+
assert.equal(parseUsesLine(` - uses: actions/checkout@${SHA} # v4`), `actions/checkout@${SHA}`);
|
|
71
|
+
assert.equal(parseUsesLine(` uses: actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("parseUsesLine returns null for comments and non-uses lines", () => {
|
|
75
|
+
assert.equal(parseUsesLine("# uses: actions/checkout@v4"), null);
|
|
76
|
+
assert.equal(parseUsesLine(" steps:"), null);
|
|
77
|
+
assert.equal(parseUsesLine(" run: echo uses: not-a-key"), null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// classifyUses (incl. subpath)
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
test("classifyUses flags external owner/repo as third-party with subpath", () => {
|
|
85
|
+
const c = classifyUses(`github/codeql-action/analyze@${SHA}`);
|
|
86
|
+
assert.equal(c.kind, "third-party");
|
|
87
|
+
assert.equal(c.owner, "github/codeql-action");
|
|
88
|
+
assert.equal(c.subpath, "analyze");
|
|
89
|
+
assert.equal(c.ref, SHA);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("classifyUses treats the default first-party owner as exempt and exposes subpath", () => {
|
|
93
|
+
const c = classifyUses(
|
|
94
|
+
`dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`
|
|
95
|
+
);
|
|
96
|
+
assert.equal(c.kind, "first-party");
|
|
97
|
+
assert.equal(c.owner, DEFAULT_FIRST_PARTY_OWNER);
|
|
98
|
+
assert.equal(c.subpath, ".github/actions/setup-toolchain");
|
|
99
|
+
assert.equal(c.ref, SHA);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("classifyUses honours a custom first-party owner", () => {
|
|
103
|
+
const c = classifyUses(`my-org/my-repo/.github/workflows/x.yml@v1`, "my-org/my-repo");
|
|
104
|
+
assert.equal(c.kind, "first-party");
|
|
105
|
+
assert.equal(c.subpath, ".github/workflows/x.yml");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("classifyUses exempts local and docker refs", () => {
|
|
109
|
+
assert.equal(classifyUses("./.github/actions/foo").kind, "local");
|
|
110
|
+
assert.equal(classifyUses("../shared/action").kind, "local");
|
|
111
|
+
assert.equal(classifyUses("docker://alpine:3.19").kind, "docker");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("classifyUses reports empty subpath for a bare owner/repo self-ref", () => {
|
|
115
|
+
const c = classifyUses(`dsj1984/mandrel-platform@${SHA}`);
|
|
116
|
+
assert.equal(c.kind, "first-party");
|
|
117
|
+
assert.equal(c.subpath, "");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("classifyUses returns unparseable for a ref with no @ or too few segments", () => {
|
|
121
|
+
assert.equal(classifyUses("").kind, "unparseable");
|
|
122
|
+
assert.equal(classifyUses("actions/checkout").kind, "unparseable");
|
|
123
|
+
assert.equal(classifyUses(`justowner@${SHA}`).kind, "unparseable");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// collectFirstPartyPins
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
test("collectFirstPartyPins keys first-party subpath refs by target", () => {
|
|
131
|
+
const content = [
|
|
132
|
+
" steps:",
|
|
133
|
+
` - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`,
|
|
134
|
+
` - uses: actions/checkout@${SHA}`, // third-party → ignored
|
|
135
|
+
` - uses: dsj1984/mandrel-platform@${SHA}`, // bare self-ref, no subpath → ignored
|
|
136
|
+
].join("\n");
|
|
137
|
+
const byTarget = collectFirstPartyPins(content, "wf.yml");
|
|
138
|
+
assert.equal(byTarget.size, 1);
|
|
139
|
+
const occs = byTarget.get("dsj1984/mandrel-platform/.github/actions/setup-toolchain");
|
|
140
|
+
assert.equal(occs.length, 1);
|
|
141
|
+
assert.equal(occs[0].ref, SHA);
|
|
142
|
+
assert.equal(occs[0].line, 2);
|
|
143
|
+
assert.equal(occs[0].file, "wf.yml");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// findSinglePinViolations (the single-pin invariant)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test("findSinglePinViolations is clean when a target is pinned consistently", () => {
|
|
151
|
+
const files = [
|
|
152
|
+
{
|
|
153
|
+
file: "a.yml",
|
|
154
|
+
content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
file: "b.yml",
|
|
158
|
+
content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
assert.deepEqual(findSinglePinViolations(files), []);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("findSinglePinViolations flags a target pinned to two different SHAs", () => {
|
|
165
|
+
const files = [
|
|
166
|
+
{
|
|
167
|
+
file: "a.yml",
|
|
168
|
+
content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
file: "b.yml",
|
|
172
|
+
content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA2}`,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
const v = findSinglePinViolations(files);
|
|
176
|
+
assert.equal(v.length, 1);
|
|
177
|
+
assert.equal(v[0].target, "dsj1984/mandrel-platform/.github/actions/foo");
|
|
178
|
+
assert.equal(v[0].shas.length, 2);
|
|
179
|
+
assert.ok(v[0].shas.includes(SHA));
|
|
180
|
+
assert.ok(v[0].shas.includes(SHA2));
|
|
181
|
+
assert.equal(v[0].occurrences.length, 2);
|
|
182
|
+
assert.deepEqual(
|
|
183
|
+
v[0].occurrences.map((o) => o.file).sort(),
|
|
184
|
+
["a.yml", "b.yml"]
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("findSinglePinViolations catches drift within a single file too", () => {
|
|
189
|
+
const files = [
|
|
190
|
+
{
|
|
191
|
+
file: "a.yml",
|
|
192
|
+
content: [
|
|
193
|
+
` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
|
|
194
|
+
` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA2}`,
|
|
195
|
+
].join("\n"),
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
const v = findSinglePinViolations(files);
|
|
199
|
+
assert.equal(v.length, 1);
|
|
200
|
+
assert.equal(v[0].shas.length, 2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("findSinglePinViolations ignores third-party targets (cross-repo dashboard owns those)", () => {
|
|
204
|
+
const files = [
|
|
205
|
+
{ file: "a.yml", content: ` - uses: github/codeql-action/analyze@${SHA}` },
|
|
206
|
+
{ file: "b.yml", content: ` - uses: github/codeql-action/analyze@${SHA2}` },
|
|
207
|
+
];
|
|
208
|
+
assert.deepEqual(findSinglePinViolations(files), []);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("findSinglePinViolations honours a custom first-party owner", () => {
|
|
212
|
+
const files = [
|
|
213
|
+
{ file: "a.yml", content: ` - uses: my-org/my-repo/actions/x@${SHA}` },
|
|
214
|
+
{ file: "b.yml", content: ` - uses: my-org/my-repo/actions/x@${SHA2}` },
|
|
215
|
+
];
|
|
216
|
+
const v = findSinglePinViolations(files, "my-org/my-repo");
|
|
217
|
+
assert.equal(v.length, 1);
|
|
218
|
+
assert.equal(v[0].target, "my-org/my-repo/actions/x");
|
|
219
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/walk.mjs
|
|
3
|
+
*
|
|
4
|
+
* The single directory-discovery seam for the pin-tooling scripts. Both
|
|
5
|
+
* `check-action-pins.mjs` and `check-workflow-portability.mjs` had grown their
|
|
6
|
+
* own `listWorkflowFiles` / `listActionFiles` pair — same intent, subtly
|
|
7
|
+
* different code (one sorted, one didn't; one guarded `statSync`, one didn't).
|
|
8
|
+
* Story #203 consolidates them here.
|
|
9
|
+
*
|
|
10
|
+
* All discovery is best-effort: a missing directory or an unreadable entry
|
|
11
|
+
* yields `[]` / is skipped rather than throwing, so a repo without a
|
|
12
|
+
* `.github/actions/` tree lints cleanly. Results are sorted for deterministic
|
|
13
|
+
* output.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readdirSync, statSync, existsSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* List `*.yml` / `*.yaml` files directly under a workflows dir
|
|
21
|
+
* (non-recursive — GitHub only runs top-level workflow files).
|
|
22
|
+
*
|
|
23
|
+
* @param {string} dir
|
|
24
|
+
* @returns {string[]} Sorted absolute/relative paths (as joined from `dir`).
|
|
25
|
+
*/
|
|
26
|
+
export function listWorkflowFiles(dir) {
|
|
27
|
+
if (!existsSync(dir)) return [];
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = readdirSync(dir);
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return entries
|
|
35
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
36
|
+
.map((f) => join(dir, f))
|
|
37
|
+
.filter((p) => {
|
|
38
|
+
try {
|
|
39
|
+
return statSync(p).isFile();
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
.sort();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Recursively list composite `action.yml` / `action.yaml` files under a dir.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} dir
|
|
51
|
+
* @returns {string[]} Sorted paths.
|
|
52
|
+
*/
|
|
53
|
+
export function listActionFiles(dir) {
|
|
54
|
+
const out = [];
|
|
55
|
+
if (!existsSync(dir)) return out;
|
|
56
|
+
const walk = (d) => {
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
const full = join(d, e.name);
|
|
65
|
+
if (e.isDirectory()) {
|
|
66
|
+
walk(full);
|
|
67
|
+
} else if (/^action\.ya?ml$/.test(e.name)) {
|
|
68
|
+
out.push(full);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
walk(dir);
|
|
73
|
+
return out.sort();
|
|
74
|
+
}
|
|
@@ -68,7 +68,9 @@ import { tmpdir } from "node:os";
|
|
|
68
68
|
import { dirname, join, resolve } from "node:path";
|
|
69
69
|
import { fileURLToPath } from "node:url";
|
|
70
70
|
|
|
71
|
-
import { buildReport,
|
|
71
|
+
import { buildReport, isFullSha } from "./check-pin-drift.mjs";
|
|
72
|
+
import { defaultGhRunner } from "./lib/gh-json.mjs";
|
|
73
|
+
import { parseSemver } from "./lib/semver-duration.mjs";
|
|
72
74
|
|
|
73
75
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
74
76
|
|
|
@@ -188,12 +190,16 @@ export function describeDrift(result) {
|
|
|
188
190
|
const short = v.pinnedSha ? v.pinnedSha.slice(0, 7) : "?";
|
|
189
191
|
out.push(`**Release lag** — workflow \`uses:\` pins \`${short}\`, behind the latest release.`);
|
|
190
192
|
}
|
|
193
|
+
// Normalize the npm version through the shared parser (Story #198): the
|
|
194
|
+
// detector already emits a dotted triple, but re-parsing keeps the repair
|
|
195
|
+
// PR body robust to a raw spec ever reaching here (`^0.11.7` → `0.11.7`).
|
|
196
|
+
const npmVersion = parseSemver(result.npm?.version ?? null) ?? result.npm?.version ?? "?";
|
|
191
197
|
if (result.surfaceSkew === true) {
|
|
192
198
|
out.push(
|
|
193
|
-
`**Surface skew** — the npm \`mandrel-platform\` dependency (\`${
|
|
199
|
+
`**Surface skew** — the npm \`mandrel-platform\` dependency (\`${npmVersion}\`) and the workflow \`uses:\` pins are on different releases.`,
|
|
194
200
|
);
|
|
195
201
|
} else if (result.npm?.npmState === "lagging") {
|
|
196
|
-
out.push(`**npm lag** — \`mandrel-platform@${
|
|
202
|
+
out.push(`**npm lag** — \`mandrel-platform@${npmVersion}\` is behind the latest release.`);
|
|
197
203
|
}
|
|
198
204
|
return out;
|
|
199
205
|
}
|