@decohere/ci-tools 0.0.1-dev.6 → 0.0.1-dev.61
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/package.json +3 -1
- package/src/audit/enforce.mjs +7 -5
- package/src/audit/format.mjs +133 -0
- package/src/audit/summarize.mjs +10 -32
- package/src/audit/upsert-clickup.mjs +117 -0
- package/src/audit/upsert-issue.mjs +22 -37
- package/src/bench/bench.mjs +2 -2
- package/src/bench/trace.mjs +1 -1
- package/src/pages/restore-bench-history.mjs +1 -2
- package/src/release/push-prerelease-commit.mjs +57 -15
- package/src/release/sync-dependencies.mjs +1 -1
- package/tests/audit/format.test.mjs +115 -0
- package/tests/lib/lcov.test.mjs +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decohere/ci-tools",
|
|
3
|
-
"version": "0.0.1-dev.
|
|
3
|
+
"version": "0.0.1-dev.61",
|
|
4
4
|
"description": "CI utility scripts for badge generation, coverage reporting, pages artifact assembly, and GitHub API tooling",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
"./badges/ci-status": "./src/badges/ci-status.mjs",
|
|
23
23
|
"./pages/artifact": "./src/pages/artifact.mjs",
|
|
24
24
|
"./pages/smoke": "./src/pages/smoke.mjs",
|
|
25
|
+
"./audit/format": "./src/audit/format.mjs",
|
|
25
26
|
"./audit/summarize": "./src/audit/summarize.mjs",
|
|
26
27
|
"./audit/enforce": "./src/audit/enforce.mjs",
|
|
27
28
|
"./audit/upsert-issue": "./src/audit/upsert-issue.mjs",
|
|
29
|
+
"./audit/upsert-clickup": "./src/audit/upsert-clickup.mjs",
|
|
28
30
|
"./bench/bench": "./src/bench/bench.mjs",
|
|
29
31
|
"./bench/trace": "./src/bench/trace.mjs",
|
|
30
32
|
"./changeset/aggregate-changelog": "./src/changeset/aggregate-changelog.mjs",
|
package/src/audit/enforce.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { formatLog, highCritical, parseAudit } from "./format.mjs";
|
|
2
3
|
|
|
3
4
|
const auditFilePath = process.argv[2] ?? "npm-audit.json";
|
|
4
5
|
|
|
@@ -10,12 +11,13 @@ try {
|
|
|
10
11
|
process.exit(1);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const critical = vulnerabilities.critical ?? 0;
|
|
14
|
+
const parsed = parseAudit(data);
|
|
15
|
+
const blocking = highCritical(parsed);
|
|
16
16
|
|
|
17
|
-
if (
|
|
18
|
-
console.error(
|
|
17
|
+
if (blocking.length > 0) {
|
|
18
|
+
console.error(
|
|
19
|
+
`Security gate failed: high=${parsed.high}, critical=${parsed.critical}\n${formatLog({ ...parsed, advisories: blocking })}`,
|
|
20
|
+
);
|
|
19
21
|
process.exit(1);
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared npm/pnpm audit JSON helpers.
|
|
3
|
+
*
|
|
4
|
+
* Supports pnpm's classic `{ advisories }` report and npm audit v2
|
|
5
|
+
* `{ auditReportVersion: 2, vulnerabilities }` (pnpm 9+/10).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const SEVERITY_ORDER = ["critical", "high", "moderate", "low", "info"];
|
|
9
|
+
|
|
10
|
+
function ghsaFromUrl(url) {
|
|
11
|
+
return typeof url === "string" ? (url.match(/GHSA-[a-z0-9-]+/i)?.[0] ?? "") : "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fromClassicAdvisories(data) {
|
|
15
|
+
return Object.values(data?.advisories ?? {}).map((raw) => {
|
|
16
|
+
const paths = (raw.findings ?? []).flatMap((f) => f.paths ?? []);
|
|
17
|
+
const url = raw.url ?? "";
|
|
18
|
+
return {
|
|
19
|
+
id: raw.id,
|
|
20
|
+
module: raw.module_name ?? "(unknown)",
|
|
21
|
+
severity: String(raw.severity ?? "info").toLowerCase(),
|
|
22
|
+
title: raw.title ?? "",
|
|
23
|
+
url,
|
|
24
|
+
vulnerable: raw.vulnerable_versions ?? "",
|
|
25
|
+
patched: raw.patched_versions ?? "",
|
|
26
|
+
cves: raw.cves ?? [],
|
|
27
|
+
ghsa: ghsaFromUrl(url),
|
|
28
|
+
paths: paths.slice(0, 3),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fromNpmV2(data) {
|
|
34
|
+
const byKey = new Map();
|
|
35
|
+
for (const vuln of Object.values(data?.vulnerabilities ?? {})) {
|
|
36
|
+
const vias = Array.isArray(vuln.via) ? vuln.via : [];
|
|
37
|
+
for (const via of vias) {
|
|
38
|
+
if (via == null || typeof via !== "object") continue;
|
|
39
|
+
const url = via.url ?? "";
|
|
40
|
+
const key = url || `${via.name ?? vuln.name}:${via.title ?? ""}`;
|
|
41
|
+
const existing = byKey.get(key);
|
|
42
|
+
const node = Array.isArray(vuln.nodes) ? vuln.nodes[0] : undefined;
|
|
43
|
+
if (existing) {
|
|
44
|
+
if (node && existing.paths.length < 3 && !existing.paths.includes(node)) {
|
|
45
|
+
existing.paths.push(node);
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
byKey.set(key, {
|
|
50
|
+
id: via.source,
|
|
51
|
+
module: via.name ?? vuln.name ?? "(unknown)",
|
|
52
|
+
severity: String(via.severity ?? vuln.severity ?? "info").toLowerCase(),
|
|
53
|
+
title: via.title ?? "",
|
|
54
|
+
url,
|
|
55
|
+
vulnerable: via.range ?? vuln.range ?? "",
|
|
56
|
+
patched: "",
|
|
57
|
+
cves: [],
|
|
58
|
+
ghsa: ghsaFromUrl(url),
|
|
59
|
+
paths: node ? [node] : [],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...byKey.values()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseAudit(data) {
|
|
67
|
+
const counts = data?.metadata?.vulnerabilities ?? {};
|
|
68
|
+
const info = counts.info ?? 0;
|
|
69
|
+
const low = counts.low ?? 0;
|
|
70
|
+
const moderate = counts.moderate ?? 0;
|
|
71
|
+
const high = counts.high ?? 0;
|
|
72
|
+
const critical = counts.critical ?? 0;
|
|
73
|
+
const total = counts.total ?? info + low + moderate + high + critical;
|
|
74
|
+
|
|
75
|
+
const classic = data?.advisories != null && Object.keys(data.advisories).length > 0;
|
|
76
|
+
const advisories = classic ? fromClassicAdvisories(data) : fromNpmV2(data);
|
|
77
|
+
|
|
78
|
+
advisories.sort((a, b) => {
|
|
79
|
+
const d = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity);
|
|
80
|
+
if (d !== 0) return d;
|
|
81
|
+
return String(a.module).localeCompare(String(b.module));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return { total, critical, high, moderate, low, info, advisories };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatLog(parsed) {
|
|
88
|
+
const lines = [
|
|
89
|
+
`npm audit: total=${parsed.total} critical=${parsed.critical} high=${parsed.high} moderate=${parsed.moderate} low=${parsed.low} info=${parsed.info}`,
|
|
90
|
+
];
|
|
91
|
+
for (const a of parsed.advisories) {
|
|
92
|
+
const id = a.ghsa || a.cves[0] || a.id;
|
|
93
|
+
lines.push(
|
|
94
|
+
` [${a.severity}] ${a.module} ${id}: ${a.title} (vulnerable ${a.vulnerable}; patched ${a.patched})`,
|
|
95
|
+
);
|
|
96
|
+
if (a.url) lines.push(` ${a.url}`);
|
|
97
|
+
if (a.paths[0]) lines.push(` ${a.paths[0]}`);
|
|
98
|
+
}
|
|
99
|
+
return `${lines.join("\n")}\n`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatMarkdown(parsed) {
|
|
103
|
+
const lines = [
|
|
104
|
+
"## npm audit",
|
|
105
|
+
"",
|
|
106
|
+
"| Severity | Count |",
|
|
107
|
+
"|---|---|",
|
|
108
|
+
`| Critical | ${parsed.critical} |`,
|
|
109
|
+
`| High | ${parsed.high} |`,
|
|
110
|
+
`| Moderate | ${parsed.moderate} |`,
|
|
111
|
+
`| Low | ${parsed.low} |`,
|
|
112
|
+
`| Info | ${parsed.info} |`,
|
|
113
|
+
`| **Total** | **${parsed.total}** |`,
|
|
114
|
+
"",
|
|
115
|
+
];
|
|
116
|
+
if (parsed.advisories.length === 0) {
|
|
117
|
+
lines.push("No advisories.");
|
|
118
|
+
return `${lines.join("\n")}\n`;
|
|
119
|
+
}
|
|
120
|
+
lines.push("### Advisories", "");
|
|
121
|
+
for (const a of parsed.advisories) {
|
|
122
|
+
const id = a.ghsa || a.cves.join(", ") || a.id;
|
|
123
|
+
const link = a.url ? `[${id}](${a.url})` : id;
|
|
124
|
+
lines.push(`- **${a.severity}** \`${a.module}\` ${link} — ${a.title}`);
|
|
125
|
+
lines.push(` - vulnerable: \`${a.vulnerable}\`; patched: \`${a.patched}\``);
|
|
126
|
+
if (a.paths[0]) lines.push(` - ${a.paths[0]}`);
|
|
127
|
+
}
|
|
128
|
+
return `${lines.join("\n")}\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function highCritical(parsed) {
|
|
132
|
+
return parsed.advisories.filter((a) => a.severity === "high" || a.severity === "critical");
|
|
133
|
+
}
|
package/src/audit/summarize.mjs
CHANGED
|
@@ -1,46 +1,24 @@
|
|
|
1
1
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
2
|
+
import { formatLog, formatMarkdown, parseAudit } from "./format.mjs";
|
|
2
3
|
|
|
3
4
|
const auditFilePath = process.argv[2] ?? "npm-audit.json";
|
|
4
5
|
|
|
5
|
-
function writeSummary(
|
|
6
|
-
const summaryText = `${lines.join("\n")}\n`;
|
|
6
|
+
function writeSummary(text) {
|
|
7
7
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
8
|
-
appendFileSync(process.env.GITHUB_STEP_SUMMARY,
|
|
9
|
-
return;
|
|
8
|
+
appendFileSync(process.env.GITHUB_STEP_SUMMARY, text);
|
|
10
9
|
}
|
|
11
|
-
process.stdout.write(summaryText);
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
let data = null;
|
|
15
13
|
try {
|
|
16
14
|
data = JSON.parse(readFileSync(auditFilePath, "utf8"));
|
|
17
15
|
} catch {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
`- See uploaded \`${auditFilePath}\` artifact for raw output.`,
|
|
23
|
-
]);
|
|
24
|
-
process.exit(0);
|
|
16
|
+
const msg = `npm audit output could not be parsed (${auditFilePath}).\n`;
|
|
17
|
+
process.stderr.write(msg);
|
|
18
|
+
writeSummary(`## Security Audit\n\n- ${msg}`);
|
|
19
|
+
process.exit(1);
|
|
25
20
|
}
|
|
26
21
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const moderate = vulnerabilities.moderate ?? 0;
|
|
31
|
-
const high = vulnerabilities.high ?? 0;
|
|
32
|
-
const critical = vulnerabilities.critical ?? 0;
|
|
33
|
-
const total = vulnerabilities.total ?? info + low + moderate + high + critical;
|
|
34
|
-
|
|
35
|
-
writeSummary([
|
|
36
|
-
"## Security Audit",
|
|
37
|
-
"",
|
|
38
|
-
`| Severity | Count |`,
|
|
39
|
-
`|-----------|-------|`,
|
|
40
|
-
`| Critical | ${critical} |`,
|
|
41
|
-
`| High | ${high} |`,
|
|
42
|
-
`| Moderate | ${moderate} |`,
|
|
43
|
-
`| Low | ${low} |`,
|
|
44
|
-
`| Info | ${info} |`,
|
|
45
|
-
`| **Total** | **${total}** |`,
|
|
46
|
-
]);
|
|
22
|
+
const parsed = parseAudit(data);
|
|
23
|
+
process.stdout.write(formatLog(parsed));
|
|
24
|
+
writeSummary(formatMarkdown(parsed));
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upsert a ClickUp task with the current npm audit report.
|
|
3
|
+
*
|
|
4
|
+
* Env: CLICKUP_API_KEY (required), CLICKUP_LIST_ID (default Decohere list).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { formatMarkdown, parseAudit } from "./format.mjs";
|
|
8
|
+
|
|
9
|
+
const auditFilePath = process.argv[2] ?? "npm-audit.json";
|
|
10
|
+
const TASK_NAME = "chore(security): npm audit findings";
|
|
11
|
+
const LIST_ID = process.env.CLICKUP_LIST_ID ?? "901220171084";
|
|
12
|
+
const API = "https://api.clickup.com/api/v2";
|
|
13
|
+
|
|
14
|
+
function runUrl() {
|
|
15
|
+
if (
|
|
16
|
+
process.env.GITHUB_SERVER_URL &&
|
|
17
|
+
process.env.GITHUB_REPOSITORY &&
|
|
18
|
+
process.env.GITHUB_RUN_ID
|
|
19
|
+
) {
|
|
20
|
+
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
21
|
+
}
|
|
22
|
+
return "(run URL unavailable)";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function clickup(method, path, body) {
|
|
26
|
+
const key = process.env.CLICKUP_API_KEY;
|
|
27
|
+
if (key == null || key.length === 0) {
|
|
28
|
+
throw new Error("Missing CLICKUP_API_KEY");
|
|
29
|
+
}
|
|
30
|
+
const response = await fetch(`${API}${path}`, {
|
|
31
|
+
method,
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: key,
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
},
|
|
36
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
37
|
+
});
|
|
38
|
+
const text = await response.text();
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`ClickUp ${method} ${path} failed (${response.status}): ${text.slice(0, 500)}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return text.length === 0 ? undefined : JSON.parse(text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function findTaskByName(name) {
|
|
48
|
+
for (let page = 0; page < 10; page++) {
|
|
49
|
+
const qs = new URLSearchParams({
|
|
50
|
+
include_closed: "true",
|
|
51
|
+
subtasks: "false",
|
|
52
|
+
page: String(page),
|
|
53
|
+
});
|
|
54
|
+
const data = await clickup("GET", `/list/${LIST_ID}/task?${qs}`);
|
|
55
|
+
const tasks = Array.isArray(data) ? data : (data?.tasks ?? []);
|
|
56
|
+
const match = tasks.find((t) => t.name === name);
|
|
57
|
+
if (match) return match;
|
|
58
|
+
if (tasks.length < 100) break;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function firstOpenStatus() {
|
|
64
|
+
const list = await clickup("GET", `/list/${LIST_ID}`);
|
|
65
|
+
return (list.statuses ?? []).find((s) => s.type === "open")?.status;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildDescription(parsed) {
|
|
69
|
+
const sha = process.env.GITHUB_SHA ? process.env.GITHUB_SHA.slice(0, 12) : "unknown";
|
|
70
|
+
return [
|
|
71
|
+
formatMarkdown(parsed).trim(),
|
|
72
|
+
"",
|
|
73
|
+
`- Commit: \`${sha}\``,
|
|
74
|
+
`- Workflow run: ${runUrl()}`,
|
|
75
|
+
"",
|
|
76
|
+
"Updated by the nightly npm-audit workflow. PR CI still gates high/critical.",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!existsSync(auditFilePath)) {
|
|
81
|
+
console.log(`No audit file at ${auditFilePath}; skipping ClickUp upsert.`);
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const parsed = parseAudit(JSON.parse(readFileSync(auditFilePath, "utf8")));
|
|
86
|
+
const existing = await findTaskByName(TASK_NAME);
|
|
87
|
+
|
|
88
|
+
if (parsed.total === 0) {
|
|
89
|
+
if (existing) {
|
|
90
|
+
await clickup("PUT", `/task/${existing.id}`, {
|
|
91
|
+
markdown_description: buildDescription(parsed),
|
|
92
|
+
});
|
|
93
|
+
console.log(`Cleared findings on ClickUp task ${existing.id}`);
|
|
94
|
+
} else {
|
|
95
|
+
console.log("No vulnerabilities and no existing ClickUp task; nothing to upsert.");
|
|
96
|
+
}
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const payload = {
|
|
101
|
+
name: TASK_NAME,
|
|
102
|
+
markdown_description: buildDescription(parsed),
|
|
103
|
+
custom_item_id: 1004,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (existing) {
|
|
107
|
+
const update = { markdown_description: payload.markdown_description };
|
|
108
|
+
if (existing.closed || existing.status?.type === "closed") {
|
|
109
|
+
const status = await firstOpenStatus();
|
|
110
|
+
if (status) update.status = status;
|
|
111
|
+
}
|
|
112
|
+
await clickup("PUT", `/task/${existing.id}`, update);
|
|
113
|
+
console.log(`Updated ClickUp task ${existing.id} (${existing.url ?? ""})`);
|
|
114
|
+
} else {
|
|
115
|
+
const created = await clickup("POST", `/list/${LIST_ID}/task`, payload);
|
|
116
|
+
console.log(`Created ClickUp task ${created.id} ${created.url ?? ""}`);
|
|
117
|
+
}
|
|
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { formatMarkdown, parseAudit } from "./format.mjs";
|
|
5
6
|
|
|
6
7
|
const auditFilePath = process.argv[2] ?? "npm-audit.json";
|
|
7
8
|
const ISSUE_TITLE = "security(ci): npm audit vulnerabilities detected";
|
|
@@ -14,40 +15,15 @@ function runGh(args) {
|
|
|
14
15
|
return result.stdout;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return { total, critical, high, moderate, low, info };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function buildBody(summary) {
|
|
30
|
-
const runUrl =
|
|
31
|
-
process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
|
|
32
|
-
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
33
|
-
: "(run URL unavailable)";
|
|
34
|
-
const sha = process.env.GITHUB_SHA ? process.env.GITHUB_SHA.slice(0, 12) : "unknown";
|
|
35
|
-
|
|
36
|
-
return [
|
|
37
|
-
"## npm audit detected vulnerabilities",
|
|
38
|
-
"",
|
|
39
|
-
`- Total: ${summary.total}`,
|
|
40
|
-
`- Critical: ${summary.critical}`,
|
|
41
|
-
`- High: ${summary.high}`,
|
|
42
|
-
`- Moderate: ${summary.moderate}`,
|
|
43
|
-
`- Low: ${summary.low}`,
|
|
44
|
-
`- Info: ${summary.info}`,
|
|
45
|
-
"",
|
|
46
|
-
`- Commit: ${sha}`,
|
|
47
|
-
`- Workflow run: ${runUrl}`,
|
|
48
|
-
"",
|
|
49
|
-
"This issue is managed by CI and will be updated on subsequent main-branch runs while vulnerabilities remain.",
|
|
50
|
-
].join("\n");
|
|
18
|
+
function runUrl() {
|
|
19
|
+
if (
|
|
20
|
+
process.env.GITHUB_SERVER_URL &&
|
|
21
|
+
process.env.GITHUB_REPOSITORY &&
|
|
22
|
+
process.env.GITHUB_RUN_ID
|
|
23
|
+
) {
|
|
24
|
+
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
25
|
+
}
|
|
26
|
+
return "(run URL unavailable)";
|
|
51
27
|
}
|
|
52
28
|
|
|
53
29
|
function findExistingIssueNumber() {
|
|
@@ -70,13 +46,22 @@ if (!existsSync(auditFilePath)) {
|
|
|
70
46
|
process.exit(0);
|
|
71
47
|
}
|
|
72
48
|
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
49
|
+
const parsed = parseAudit(JSON.parse(readFileSync(auditFilePath, "utf8")));
|
|
50
|
+
if (parsed.total === 0) {
|
|
75
51
|
console.log("No vulnerabilities found; skipping issue create/update.");
|
|
76
52
|
process.exit(0);
|
|
77
53
|
}
|
|
78
54
|
|
|
79
|
-
const
|
|
55
|
+
const sha = process.env.GITHUB_SHA ? process.env.GITHUB_SHA.slice(0, 12) : "unknown";
|
|
56
|
+
const body = [
|
|
57
|
+
formatMarkdown(parsed).trim(),
|
|
58
|
+
"",
|
|
59
|
+
`- Commit: \`${sha}\``,
|
|
60
|
+
`- Workflow run: ${runUrl()}`,
|
|
61
|
+
"",
|
|
62
|
+
"This issue is managed by CI and will be updated on subsequent main-branch runs while vulnerabilities remain.",
|
|
63
|
+
].join("\n");
|
|
64
|
+
|
|
80
65
|
const tempFile = path.join(os.tmpdir(), `npm-audit-issue-${Date.now()}.md`);
|
|
81
66
|
writeFileSync(tempFile, body, "utf8");
|
|
82
67
|
|
package/src/bench/bench.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* bench.mjs — run the
|
|
4
|
+
* bench.mjs — run the forge benchmark and compare against the last saved baseline.
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
7
|
* node bench.mjs — run and compare, do not save
|
|
@@ -34,7 +34,7 @@ const HISTORY_FILE = join(
|
|
|
34
34
|
"projects/ci-tools/dist/ci/bench/bench-history.json",
|
|
35
35
|
);
|
|
36
36
|
const VITEST_BIN = join(WORKSPACE_ROOT, "node_modules/.bin/vitest");
|
|
37
|
-
const BENCH_FILE = "projects/
|
|
37
|
+
const BENCH_FILE = "projects/terminal/e2e/build.benchmark.bench.ts";
|
|
38
38
|
const CONFIG = join(WORKSPACE_ROOT, "vitest.e2e.config.ts");
|
|
39
39
|
const TRACE_LOG_DIR = join(WORKSPACE_ROOT, ".decohere", "logs");
|
|
40
40
|
const MAX_RUNS = 10;
|
package/src/bench/trace.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
const WORKSPACE_ROOT = process.cwd();
|
|
8
8
|
const VITEST_BIN = join(WORKSPACE_ROOT, "node_modules/.bin/vitest");
|
|
9
9
|
const CONFIG = join(WORKSPACE_ROOT, "vitest.e2e.config.ts");
|
|
10
|
-
const TRACE_SPEC = "projects/
|
|
10
|
+
const TRACE_SPEC = "projects/terminal/e2e/build.trace.e2e.spec.ts";
|
|
11
11
|
const TRACE_LOG_DIR = join(WORKSPACE_ROOT, ".decohere", "logs");
|
|
12
12
|
|
|
13
13
|
function truthy(value, defaultValue = false) {
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
// Restore benchmark history from Pages, or initialize empty if not found
|
|
4
4
|
|
|
5
5
|
import { execSync } from "node:child_process";
|
|
6
|
-
import {
|
|
6
|
+
import { writeFileSync } from "node:fs";
|
|
7
7
|
import { mkdir } from "node:fs/promises";
|
|
8
|
-
import { dirname } from "node:path";
|
|
9
8
|
|
|
10
9
|
const HISTORY_DIR = "projects/ci-tools/dist/ci/bench";
|
|
11
10
|
const HISTORY_FILE = `${HISTORY_DIR}/bench-history.json`;
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* Validates working tree
|
|
3
|
+
* Publish prerelease version commit via PR
|
|
4
|
+
* Validates working tree, pushes prerelease commit to a release branch,
|
|
5
|
+
* and creates/updates a PR targeting the protected base branch.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { execSync } from "node:child_process";
|
|
8
9
|
|
|
10
|
+
function run(command) {
|
|
11
|
+
return execSync(command, { encoding: "utf8" }).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function runInherit(command, env) {
|
|
15
|
+
execSync(command, { stdio: "inherit", env });
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
try {
|
|
10
19
|
// Check if working tree is dirty
|
|
11
|
-
const status =
|
|
20
|
+
const status = run("git status --porcelain");
|
|
12
21
|
if (status.trim().length > 0) {
|
|
13
22
|
console.error(
|
|
14
23
|
"::error::Working tree is dirty after version/dependency sync; refusing to publish with uncommitted changes.",
|
|
@@ -17,25 +26,58 @@ try {
|
|
|
17
26
|
process.exit(1);
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
// Compare HEAD with origin
|
|
21
|
-
const head =
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
// Compare HEAD with origin and create a PR branch targeting an explicit base branch.
|
|
30
|
+
const head = run("git rev-parse HEAD");
|
|
31
|
+
const sourceBranch = process.env.GITHUB_REF_NAME || run("git rev-parse --abbrev-ref HEAD");
|
|
32
|
+
const channel = process.env.RELEASE_CHANNEL ?? "beta";
|
|
33
|
+
const baseBranch = process.env.RELEASE_BASE_BRANCH || sourceBranch;
|
|
34
|
+
const releasePrBranch = `release/${channel}-${sourceBranch}-to-${baseBranch}`;
|
|
35
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
27
36
|
|
|
28
37
|
try {
|
|
29
|
-
const origin =
|
|
38
|
+
const origin = run(`git rev-parse origin/${sourceBranch}`);
|
|
30
39
|
|
|
31
40
|
if (head !== origin) {
|
|
32
|
-
console.log(
|
|
33
|
-
|
|
41
|
+
console.log(
|
|
42
|
+
`Publishing release commit via PR branch origin/${releasePrBranch} (base: origin/${baseBranch})`,
|
|
43
|
+
);
|
|
44
|
+
runInherit(`git push origin HEAD:${releasePrBranch} --force`, process.env);
|
|
45
|
+
|
|
46
|
+
if (token == null || token.length === 0) {
|
|
47
|
+
throw new Error("Missing GITHUB_TOKEN/GH_TOKEN for PR upsert");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const version = process.env.RELEASE_VERSION ?? "(unknown)";
|
|
51
|
+
const ownerRepo = run("git config --get remote.origin.url")
|
|
52
|
+
.replace(/^.*github\.com[:/]/, "")
|
|
53
|
+
.replace(/\.git$/, "");
|
|
54
|
+
|
|
55
|
+
runInherit(
|
|
56
|
+
[
|
|
57
|
+
"node projects/ci-tools/src/github/create-or-update-pr.mjs",
|
|
58
|
+
`--title "chore(release): ${channel} ${version}"`,
|
|
59
|
+
`--base "${baseBranch}"`,
|
|
60
|
+
`--head "${releasePrBranch}"`,
|
|
61
|
+
`--body "Automated ${channel} version bump and dependency sync.\\n\\n- Channel: ${channel}\\n- Version: ${version}\\n- Source branch: ${sourceBranch}\\n- Base branch: ${baseBranch}\\n- Source commit: ${head}\\n\\nGenerated by pull-request workflow."`,
|
|
62
|
+
`--owner "${ownerRepo.split("/")[0]}"`,
|
|
63
|
+
`--repo "${ownerRepo.split("/")[1]}"`,
|
|
64
|
+
].join(" "),
|
|
65
|
+
{ ...process.env, GITHUB_TOKEN: token },
|
|
66
|
+
);
|
|
34
67
|
} else {
|
|
35
68
|
console.log("No prerelease version commit to push.");
|
|
36
69
|
}
|
|
37
|
-
} catch {
|
|
38
|
-
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const message = err?.message ?? "";
|
|
72
|
+
if (
|
|
73
|
+
message.includes(`origin/${sourceBranch}`) &&
|
|
74
|
+
message.includes("Needed a single revision")
|
|
75
|
+
) {
|
|
76
|
+
console.log(`Remote branch origin/${sourceBranch} not found, skipping push.`);
|
|
77
|
+
} else {
|
|
78
|
+
console.error("::error::Failed to publish release commit via PR branch.");
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
39
81
|
}
|
|
40
82
|
} catch (e) {
|
|
41
83
|
console.error(`Push failed: ${e.message}`);
|
|
@@ -10,7 +10,7 @@ const DRY_RUN = process.env.DRY_RUN === "true";
|
|
|
10
10
|
const TERMINAL_CLI = process.env.TERMINAL_CLI || "dist/debug/projects/terminal/src/cli.js";
|
|
11
11
|
const DECOHERE_CONFIG = process.env.DECOHERE_CONFIG || ".decohere.js";
|
|
12
12
|
|
|
13
|
-
const baseCmd = `node "${TERMINAL_CLI}" --log-level
|
|
13
|
+
const baseCmd = `node "${TERMINAL_CLI}" --log-level info --config-file "${DECOHERE_CONFIG}" forge dependencies --configuration release sync`;
|
|
14
14
|
|
|
15
15
|
try {
|
|
16
16
|
if (DRY_RUN) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatLog,
|
|
4
|
+
formatMarkdown,
|
|
5
|
+
highCritical,
|
|
6
|
+
parseAudit,
|
|
7
|
+
} from "../../src/audit/format.mjs";
|
|
8
|
+
|
|
9
|
+
const fixture = {
|
|
10
|
+
metadata: {
|
|
11
|
+
vulnerabilities: { info: 0, low: 1, moderate: 0, high: 1, critical: 0, total: 2 },
|
|
12
|
+
},
|
|
13
|
+
advisories: {
|
|
14
|
+
1: {
|
|
15
|
+
id: 1,
|
|
16
|
+
module_name: "nanoid",
|
|
17
|
+
severity: "high",
|
|
18
|
+
title: "nanoid: custom generators can loop indefinitely when size is zero",
|
|
19
|
+
url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
|
|
20
|
+
vulnerable_versions: "<3.3.18",
|
|
21
|
+
patched_versions: ">=3.3.18",
|
|
22
|
+
cves: [],
|
|
23
|
+
findings: [{ version: "3.3.17", paths: [". > vite > postcss > nanoid@3.3.17"] }],
|
|
24
|
+
},
|
|
25
|
+
2: {
|
|
26
|
+
id: 2,
|
|
27
|
+
module_name: "esbuild",
|
|
28
|
+
severity: "low",
|
|
29
|
+
title: "esbuild Windows file read",
|
|
30
|
+
url: "https://github.com/advisories/GHSA-g7r4-m6w7-qqqr",
|
|
31
|
+
vulnerable_versions: "<0.28.1",
|
|
32
|
+
patched_versions: ">=0.28.1",
|
|
33
|
+
cves: [],
|
|
34
|
+
findings: [{ version: "0.27.3", paths: [". > vite > esbuild"] }],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("parseAudit", () => {
|
|
40
|
+
it("reads severity counts and sorts advisories critical/high first", () => {
|
|
41
|
+
const parsed = parseAudit(fixture);
|
|
42
|
+
expect(parsed.total).toBe(2);
|
|
43
|
+
expect(parsed.high).toBe(1);
|
|
44
|
+
expect(parsed.low).toBe(1);
|
|
45
|
+
expect(parsed.advisories.map((a) => a.module)).toEqual(["nanoid", "esbuild"]);
|
|
46
|
+
expect(parsed.advisories[0]?.severity).toBe("high");
|
|
47
|
+
expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("formatLog", () => {
|
|
52
|
+
it("prints severity counts and advisory lines including GHSA", () => {
|
|
53
|
+
const log = formatLog(parseAudit(fixture));
|
|
54
|
+
expect(log).toContain("high=1");
|
|
55
|
+
expect(log).toContain("[high] nanoid GHSA-2v37-7h3g-55p8");
|
|
56
|
+
expect(log).toContain("[low] esbuild");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("formatMarkdown", () => {
|
|
61
|
+
it("includes a severity table and linked advisories", () => {
|
|
62
|
+
const md = formatMarkdown(parseAudit(fixture));
|
|
63
|
+
expect(md).toContain("| High | 1 |");
|
|
64
|
+
expect(md).toContain("**high** `nanoid`");
|
|
65
|
+
expect(md).toContain("GHSA-2v37-7h3g-55p8");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("highCritical", () => {
|
|
70
|
+
it("filters to high and critical only", () => {
|
|
71
|
+
expect(highCritical(parseAudit(fixture)).map((a) => a.module)).toEqual(["nanoid"]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("parseAudit npm v2", () => {
|
|
76
|
+
it("dedupes GHSA rows from package via objects", () => {
|
|
77
|
+
const v2 = {
|
|
78
|
+
auditReportVersion: 2,
|
|
79
|
+
metadata: {
|
|
80
|
+
vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0, total: 1 },
|
|
81
|
+
},
|
|
82
|
+
vulnerabilities: {
|
|
83
|
+
nanoid: {
|
|
84
|
+
name: "nanoid",
|
|
85
|
+
severity: "high",
|
|
86
|
+
via: [
|
|
87
|
+
{
|
|
88
|
+
source: 1103848,
|
|
89
|
+
name: "nanoid",
|
|
90
|
+
title: "nanoid infinite loop",
|
|
91
|
+
url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
|
|
92
|
+
severity: "high",
|
|
93
|
+
range: "<3.3.18",
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
range: "<3.3.18",
|
|
97
|
+
nodes: ["node_modules/.pnpm/nanoid@3.3.17/node_modules/nanoid"],
|
|
98
|
+
},
|
|
99
|
+
postcss: {
|
|
100
|
+
name: "postcss",
|
|
101
|
+
severity: "high",
|
|
102
|
+
via: ["nanoid"],
|
|
103
|
+
effects: [],
|
|
104
|
+
range: "*",
|
|
105
|
+
nodes: ["node_modules/.pnpm/postcss@8.5.26/node_modules/postcss"],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const parsed = parseAudit(v2);
|
|
110
|
+
expect(parsed.advisories).toHaveLength(1);
|
|
111
|
+
expect(parsed.advisories[0]?.module).toBe("nanoid");
|
|
112
|
+
expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
|
|
113
|
+
expect(parsed.advisories[0]?.vulnerable).toBe("<3.3.18");
|
|
114
|
+
});
|
|
115
|
+
});
|
package/tests/lib/lcov.test.mjs
CHANGED
|
@@ -5,11 +5,11 @@ import {
|
|
|
5
5
|
parseLcovFiles,
|
|
6
6
|
} from "../../src/lib/lcov.mjs";
|
|
7
7
|
|
|
8
|
-
const sampleLcov = `SF:projects/
|
|
8
|
+
const sampleLcov = `SF:projects/forge/src/index.ts
|
|
9
9
|
LF:10
|
|
10
10
|
LH:9
|
|
11
11
|
end_of_record
|
|
12
|
-
SF:projects/
|
|
12
|
+
SF:projects/forge/src/build/index.ts
|
|
13
13
|
LF:20
|
|
14
14
|
LH:15
|
|
15
15
|
end_of_record
|
|
@@ -32,7 +32,7 @@ end_of_record
|
|
|
32
32
|
|
|
33
33
|
describe("getProjectFromPath", () => {
|
|
34
34
|
it("extracts project name from projects/ path", () => {
|
|
35
|
-
expect(getProjectFromPath("projects/
|
|
35
|
+
expect(getProjectFromPath("projects/forge/src/index.ts")).toBe("forge");
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
it("returns undefined for unrecognized paths", () => {
|
|
@@ -43,16 +43,16 @@ describe("getProjectFromPath", () => {
|
|
|
43
43
|
describe("parseLcovByProject", () => {
|
|
44
44
|
it("groups files by project and sums coverage", () => {
|
|
45
45
|
const result = parseLcovByProject(sampleLcov);
|
|
46
|
-
expect(result.get("
|
|
47
|
-
expect(result.get("
|
|
48
|
-
expect(result.get("
|
|
46
|
+
expect(result.get("forge")).toBeDefined();
|
|
47
|
+
expect(result.get("forge").totalLines).toBe(30);
|
|
48
|
+
expect(result.get("forge").coveredLines).toBe(24);
|
|
49
49
|
expect(result.get("nexus")).toBeDefined();
|
|
50
50
|
expect(result.get("nexus").totalLines).toBe(5);
|
|
51
51
|
expect(result.get("nexus").coveredLines).toBe(5);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
it("ignores records with LF:0", () => {
|
|
55
|
-
const lcov = `SF:projects/
|
|
55
|
+
const lcov = `SF:projects/forge/src/empty.ts\nLF:0\nLH:0\nend_of_record\n`;
|
|
56
56
|
const result = parseLcovByProject(lcov);
|
|
57
57
|
expect(result.size).toBe(0);
|
|
58
58
|
});
|