mandrel-platform 0.27.1 → 0.29.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/package.json
CHANGED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Unit coverage for the OSV report severity-band gate (Story #310).
|
|
2
|
+
//
|
|
3
|
+
// This logic used to live as a heredoc inside pr-quality.yml and was
|
|
4
|
+
// therefore untestable. The banding thresholds, the Story #145 allow-list
|
|
5
|
+
// schema validation, and the `revisitBy` re-gating are the load-bearing
|
|
6
|
+
// pieces — a silent regression in any of them either lets a real advisory
|
|
7
|
+
// through or blocks a legitimately-suppressed one. These tests pin them.
|
|
8
|
+
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
bandOf,
|
|
14
|
+
collectRows,
|
|
15
|
+
loadAllowlist,
|
|
16
|
+
classify,
|
|
17
|
+
findingsDigest,
|
|
18
|
+
renderSummary,
|
|
19
|
+
OsvGateError,
|
|
20
|
+
} from "../.github/actions/osv-scan/osv-report-gate.mjs";
|
|
21
|
+
|
|
22
|
+
// Build an OSV-scanner-shaped report for one grouped advisory.
|
|
23
|
+
const reportWith = (groups) => ({
|
|
24
|
+
results: [
|
|
25
|
+
{
|
|
26
|
+
source: { path: "pnpm-lock.yaml" },
|
|
27
|
+
packages: groups.map((g) => ({
|
|
28
|
+
package: { name: g.name, version: g.version || "1.0.0", ecosystem: g.ecosystem || "npm" },
|
|
29
|
+
groups: [{ ids: g.ids, max_severity: g.score }],
|
|
30
|
+
})),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("bandOf buckets CVSS scores into the documented bands", () => {
|
|
36
|
+
assert.equal(bandOf(9.8), "critical");
|
|
37
|
+
assert.equal(bandOf(9.0), "critical");
|
|
38
|
+
assert.equal(bandOf(7.5), "high");
|
|
39
|
+
assert.equal(bandOf(7.0), "high");
|
|
40
|
+
assert.equal(bandOf(4.0), "medium");
|
|
41
|
+
assert.equal(bandOf(6.9), "medium");
|
|
42
|
+
assert.equal(bandOf(0.1), "low");
|
|
43
|
+
assert.equal(bandOf(0), "none");
|
|
44
|
+
assert.equal(bandOf(NaN), "none");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a high finding blocks at the default gate; a medium finding warns", () => {
|
|
48
|
+
const rows = collectRows(
|
|
49
|
+
reportWith([
|
|
50
|
+
{ name: "brace-expansion", ids: ["GHSA-3jxr-9vmj-r5cp"], score: "7.5" },
|
|
51
|
+
{ name: "some-medium", ids: ["GHSA-medium"], score: "5.0" },
|
|
52
|
+
]),
|
|
53
|
+
);
|
|
54
|
+
const v = classify(rows, { failOn: "high" });
|
|
55
|
+
assert.equal(v.blocking.length, 1);
|
|
56
|
+
assert.equal(v.blocking[0].ids[0], "GHSA-3jxr-9vmj-r5cp");
|
|
57
|
+
assert.equal(v.warning.length, 1);
|
|
58
|
+
assert.equal(v.warning[0].ids[0], "GHSA-medium");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("critical, high, medium, low, none all classify against a high gate", () => {
|
|
62
|
+
const rows = collectRows(
|
|
63
|
+
reportWith([
|
|
64
|
+
{ name: "crit", ids: ["C"], score: "9.9" },
|
|
65
|
+
{ name: "hi", ids: ["H"], score: "7.1" },
|
|
66
|
+
{ name: "med", ids: ["M"], score: "4.5" },
|
|
67
|
+
{ name: "lo", ids: ["L"], score: "1.0" },
|
|
68
|
+
{ name: "un", ids: ["U"], score: "" }, // unscored → none
|
|
69
|
+
]),
|
|
70
|
+
);
|
|
71
|
+
const v = classify(rows, { failOn: "high" });
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
v.blocking.map((r) => r.band),
|
|
74
|
+
["critical", "high"],
|
|
75
|
+
);
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
v.warning.map((r) => r.band),
|
|
78
|
+
["medium", "low", "none"],
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("an advisory with no group still counts as an unscored 'none' finding", () => {
|
|
83
|
+
const report = {
|
|
84
|
+
results: [
|
|
85
|
+
{
|
|
86
|
+
source: { path: "package-lock.json" },
|
|
87
|
+
packages: [
|
|
88
|
+
{
|
|
89
|
+
package: { name: "loner", version: "2.0.0", ecosystem: "npm" },
|
|
90
|
+
vulnerabilities: [{ id: "GHSA-ungrouped" }],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
const rows = collectRows(report);
|
|
97
|
+
assert.equal(rows.length, 1);
|
|
98
|
+
assert.equal(rows[0].band, "none");
|
|
99
|
+
assert.equal(rows[0].ids[0], "GHSA-ungrouped");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a missing allow-list yields gating identical to no allow-list", () => {
|
|
103
|
+
// exists() returns false → empty list, no throw.
|
|
104
|
+
const allowlist = loadAllowlist(".osv-allowlist.json", { exists: () => false });
|
|
105
|
+
assert.deepEqual(allowlist, []);
|
|
106
|
+
|
|
107
|
+
const rows = collectRows(reportWith([{ name: "brace-expansion", ids: ["GHSA-x"], score: "7.5" }]));
|
|
108
|
+
const withMissing = classify(rows, { failOn: "high", allowlist });
|
|
109
|
+
const withNone = classify(rows, { failOn: "high", allowlist: [] });
|
|
110
|
+
assert.deepEqual(withMissing.blocking, withNone.blocking);
|
|
111
|
+
assert.equal(withMissing.blocking.length, 1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a present-but-malformed allow-list is a hard error, not a silent match", () => {
|
|
115
|
+
const opts = { exists: () => true, readFile: () => "{ not json" };
|
|
116
|
+
assert.throws(() => loadAllowlist(".osv-allowlist.json", opts), OsvGateError);
|
|
117
|
+
|
|
118
|
+
const notArray = { exists: () => true, readFile: () => JSON.stringify({ nope: true }) };
|
|
119
|
+
assert.throws(() => loadAllowlist(".osv-allowlist.json", notArray), OsvGateError);
|
|
120
|
+
|
|
121
|
+
const missingReason = {
|
|
122
|
+
exists: () => true,
|
|
123
|
+
readFile: () => JSON.stringify([{ id: "GHSA-x", revisitBy: "2099-01-01" }]),
|
|
124
|
+
};
|
|
125
|
+
assert.throws(() => loadAllowlist(".osv-allowlist.json", missingReason), OsvGateError);
|
|
126
|
+
|
|
127
|
+
const badDate = {
|
|
128
|
+
exists: () => true,
|
|
129
|
+
readFile: () => JSON.stringify([{ id: "GHSA-x", reason: "triaged", revisitBy: "soon" }]),
|
|
130
|
+
};
|
|
131
|
+
assert.throws(() => loadAllowlist(".osv-allowlist.json", badDate), OsvGateError);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("an unexpired suppression moves a blocking finding to suppressed", () => {
|
|
135
|
+
const rows = collectRows(
|
|
136
|
+
reportWith([{ name: "brace-expansion", ids: ["GHSA-3jxr-9vmj-r5cp"], score: "7.5" }]),
|
|
137
|
+
);
|
|
138
|
+
const allowlist = [
|
|
139
|
+
{ id: "GHSA-3jxr-9vmj-r5cp", reason: "no reachable sink", revisitBy: "2099-12-31" },
|
|
140
|
+
];
|
|
141
|
+
const v = classify(rows, { failOn: "high", allowlist, today: "2026-07-21" });
|
|
142
|
+
assert.equal(v.blocking.length, 0);
|
|
143
|
+
assert.equal(v.suppressed.length, 1);
|
|
144
|
+
assert.equal(v.expired.length, 0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a suppression past revisitBy re-gates as blocking", () => {
|
|
148
|
+
const rows = collectRows(
|
|
149
|
+
reportWith([{ name: "brace-expansion", ids: ["GHSA-3jxr-9vmj-r5cp"], score: "7.5" }]),
|
|
150
|
+
);
|
|
151
|
+
const allowlist = [
|
|
152
|
+
{ id: "GHSA-3jxr-9vmj-r5cp", reason: "stale triage", revisitBy: "2026-01-01" },
|
|
153
|
+
];
|
|
154
|
+
const v = classify(rows, { failOn: "high", allowlist, today: "2026-07-21" });
|
|
155
|
+
assert.equal(v.blocking.length, 1);
|
|
156
|
+
assert.equal(v.expired.length, 1);
|
|
157
|
+
assert.equal(v.suppressed.length, 0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a package/ecosystem-scoped suppression only matches its own package", () => {
|
|
161
|
+
const rows = collectRows(
|
|
162
|
+
reportWith([
|
|
163
|
+
{ name: "brace-expansion", ids: ["GHSA-shared"], score: "7.5" },
|
|
164
|
+
{ name: "other-pkg", ids: ["GHSA-shared"], score: "7.5" },
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
const allowlist = [
|
|
168
|
+
{
|
|
169
|
+
id: "GHSA-shared",
|
|
170
|
+
reason: "only brace-expansion is unreachable",
|
|
171
|
+
revisitBy: "2099-12-31",
|
|
172
|
+
package: "brace-expansion",
|
|
173
|
+
ecosystem: "npm",
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
const v = classify(rows, { failOn: "high", allowlist, today: "2026-07-21" });
|
|
177
|
+
assert.equal(v.suppressed.length, 1);
|
|
178
|
+
assert.equal(v.suppressed[0].name, "brace-expansion");
|
|
179
|
+
assert.equal(v.blocking.length, 1);
|
|
180
|
+
assert.equal(v.blocking[0].name, "other-pkg");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("an invalid fail-on band is a hard error", () => {
|
|
184
|
+
assert.throws(() => classify([], { failOn: "sky-high" }), OsvGateError);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("findingsDigest is stable across row order and ignores below-gate rows", () => {
|
|
188
|
+
const a = collectRows(
|
|
189
|
+
reportWith([
|
|
190
|
+
{ name: "p1", ids: ["GHSA-a"], score: "7.5" },
|
|
191
|
+
{ name: "p2", ids: ["GHSA-b"], score: "9.1" },
|
|
192
|
+
]),
|
|
193
|
+
);
|
|
194
|
+
const b = collectRows(
|
|
195
|
+
reportWith([
|
|
196
|
+
{ name: "p2", ids: ["GHSA-b"], score: "9.1" },
|
|
197
|
+
{ name: "p1", ids: ["GHSA-a"], score: "7.5" },
|
|
198
|
+
]),
|
|
199
|
+
);
|
|
200
|
+
const va = classify(a, { failOn: "high" });
|
|
201
|
+
const vb = classify(b, { failOn: "high" });
|
|
202
|
+
assert.equal(findingsDigest(va.blocking), findingsDigest(vb.blocking));
|
|
203
|
+
|
|
204
|
+
// A new blocking advisory changes the digest.
|
|
205
|
+
const c = collectRows(
|
|
206
|
+
reportWith([
|
|
207
|
+
{ name: "p1", ids: ["GHSA-a"], score: "7.5" },
|
|
208
|
+
{ name: "p2", ids: ["GHSA-b"], score: "9.1" },
|
|
209
|
+
{ name: "p3", ids: ["GHSA-c"], score: "8.0" },
|
|
210
|
+
]),
|
|
211
|
+
);
|
|
212
|
+
const vc = classify(c, { failOn: "high" });
|
|
213
|
+
assert.notEqual(findingsDigest(va.blocking), findingsDigest(vc.blocking));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("renderSummary reports a clean scan and a blocked scan distinctly", () => {
|
|
217
|
+
const clean = renderSummary(classify([], { failOn: "high" }));
|
|
218
|
+
assert.match(clean.join("\n"), /no known advisories/);
|
|
219
|
+
|
|
220
|
+
const blocked = renderSummary(
|
|
221
|
+
classify(collectRows(reportWith([{ name: "p", ids: ["GHSA-x"], score: "9.0" }])), {
|
|
222
|
+
failOn: "high",
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
assert.match(blocked.join("\n"), /❌ BLOCKED/);
|
|
226
|
+
assert.match(blocked.join("\n"), /GHSA-x/);
|
|
227
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Unit coverage for the OSV tracking-issue upsert verdict (Story #310).
|
|
2
|
+
//
|
|
3
|
+
// The whole point of the scheduled advisory workflow is a SINGLE tracking
|
|
4
|
+
// issue that does not spam: it must open once, stay quiet while the finding
|
|
5
|
+
// set is unchanged, update only on a real change, and close when the set
|
|
6
|
+
// clears. That contract is the pure `decideVerdict` function — these tests
|
|
7
|
+
// pin every branch of it, plus the marker round-trip and the gh-driven
|
|
8
|
+
// lookup, without any network access.
|
|
9
|
+
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
decideVerdict,
|
|
15
|
+
extractDigest,
|
|
16
|
+
digestMarker,
|
|
17
|
+
buildIssueBody,
|
|
18
|
+
findTrackingIssue,
|
|
19
|
+
TRACKER_MARKER,
|
|
20
|
+
} from "../.github/actions/osv-track-issue/osv-track-issue.mjs";
|
|
21
|
+
|
|
22
|
+
const issueWithDigest = (number, digest) => ({
|
|
23
|
+
number,
|
|
24
|
+
body: buildIssueBody({ digest, summary: "…", repo: "acme/app", branch: "main" }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("CREATE when blocking findings exist and no tracking issue is open", () => {
|
|
28
|
+
const v = decideVerdict(null, { blockingCount: 2, digest: "abcd1234-2" });
|
|
29
|
+
assert.equal(v.action, "create");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("NOOP when the open issue already reflects this exact finding set", () => {
|
|
33
|
+
const existing = issueWithDigest(42, "abcd1234-2");
|
|
34
|
+
const v = decideVerdict(existing, { blockingCount: 2, digest: "abcd1234-2" });
|
|
35
|
+
assert.equal(v.action, "noop");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("UPDATE when the finding-set digest changed since the issue was written", () => {
|
|
39
|
+
const existing = issueWithDigest(42, "abcd1234-2");
|
|
40
|
+
const v = decideVerdict(existing, { blockingCount: 3, digest: " zzzz9999-3".trim() });
|
|
41
|
+
assert.equal(v.action, "update");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("CLOSE when the blocking set is now empty but an issue is still open", () => {
|
|
45
|
+
const existing = issueWithDigest(42, "abcd1234-2");
|
|
46
|
+
const v = decideVerdict(existing, { blockingCount: 0, digest: "empty-0" });
|
|
47
|
+
assert.equal(v.action, "close");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("NOOP when there are no blocking findings and no issue to close", () => {
|
|
51
|
+
const v = decideVerdict(null, { blockingCount: 0, digest: "empty-0" });
|
|
52
|
+
assert.equal(v.action, "noop");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a finding set of only allow-list-suppressed advisories never opens an issue", () => {
|
|
56
|
+
// Suppressed / below-gate findings do not count toward blockingCount, so the
|
|
57
|
+
// gate hands this path blockingCount: 0 — verdict must be close/noop, not create.
|
|
58
|
+
assert.equal(decideVerdict(null, { blockingCount: 0, digest: "empty-0" }).action, "noop");
|
|
59
|
+
const existing = issueWithDigest(7, "abcd1234-1");
|
|
60
|
+
assert.equal(decideVerdict(existing, { blockingCount: 0, digest: "empty-0" }).action, "close");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the digest marker round-trips through a rendered issue body", () => {
|
|
64
|
+
assert.match(digestMarker("deadbeef-4"), /mandrel:osv-advisory-digest: deadbeef-4/);
|
|
65
|
+
const body = buildIssueBody({ digest: "deadbeef-4", summary: "s", repo: "a/b", branch: "main" });
|
|
66
|
+
assert.ok(body.includes(TRACKER_MARKER));
|
|
67
|
+
assert.equal(extractDigest(body), "deadbeef-4");
|
|
68
|
+
assert.equal(extractDigest("no markers here"), null);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("findTrackingIssue confirms the marker rather than trusting the search hint", () => {
|
|
72
|
+
const calls = [];
|
|
73
|
+
const runner = (args, opts) => {
|
|
74
|
+
calls.push({ args, opts });
|
|
75
|
+
// gh's `in:body` search is fuzzy — return one true match and one false positive.
|
|
76
|
+
return JSON.stringify([
|
|
77
|
+
{ number: 99, body: "unrelated issue mentioning osv-advisory in prose" },
|
|
78
|
+
{ number: 100, body: `${TRACKER_MARKER}\n${digestMarker("x-1")}\nbody` },
|
|
79
|
+
]);
|
|
80
|
+
};
|
|
81
|
+
const found = findTrackingIssue({ repo: "acme/app", labels: ["security"] }, runner);
|
|
82
|
+
assert.equal(found.number, 100);
|
|
83
|
+
// The label scope is forwarded to gh.
|
|
84
|
+
assert.ok(calls[0].args.includes("--label"));
|
|
85
|
+
assert.ok(calls[0].args.includes("security"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("findTrackingIssue returns null when nothing carries the marker", () => {
|
|
89
|
+
const runner = () => JSON.stringify([{ number: 1, body: "no marker" }]);
|
|
90
|
+
assert.equal(findTrackingIssue({ repo: "acme/app", labels: [] }, runner), null);
|
|
91
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# resolve-diff-range.sh — the SINGLE, event-agnostic base/head SHA derivation
|
|
3
|
+
# shared by every diff-scoped tier of pr-quality.yml (Story #314).
|
|
4
|
+
#
|
|
5
|
+
# WHY THIS EXISTS
|
|
6
|
+
# ---------------
|
|
7
|
+
# The diff-scoped security tiers (gitleaks secret scan + Semgrep SAST) must
|
|
8
|
+
# scope their scan to the commits the triggering event INTRODUCED, so a
|
|
9
|
+
# pre-existing finding never blocks. Each event exposes that base/head pair
|
|
10
|
+
# under a different context path:
|
|
11
|
+
#
|
|
12
|
+
# pull_request → github.event.pull_request.base.sha / .head.sha
|
|
13
|
+
# merge_group → github.event.merge_group.base_sha / .head_sha
|
|
14
|
+
# push → github.event.before / github.sha
|
|
15
|
+
#
|
|
16
|
+
# Under a `merge_group` (merge-queue) event `github.event.pull_request.*` is
|
|
17
|
+
# empty, so without this derivation the tiers degrade to a FULL-TREE scan and
|
|
18
|
+
# surface pre-existing findings unrelated to the queued commits — bouncing the
|
|
19
|
+
# whole queue batch. Deriving base/head from the merge_group context instead
|
|
20
|
+
# keeps the queue diff-scoped. This file is the one place that classification
|
|
21
|
+
# lives, so gitleaks and SAST cannot drift apart.
|
|
22
|
+
#
|
|
23
|
+
# CONTRACT
|
|
24
|
+
# --------
|
|
25
|
+
# SOURCE this file (do NOT exec it) from a `shell: bash` step, after the repo
|
|
26
|
+
# has been checked out with `fetch-depth: 0`. It reads the env vars below (all
|
|
27
|
+
# optional; an absent context evaluates to the empty string in a GitHub
|
|
28
|
+
# expression, so nothing here can `startup_failure` on a missing context) and
|
|
29
|
+
# sets three variables in the CALLER's shell:
|
|
30
|
+
#
|
|
31
|
+
# Inputs (wire each to the matching context in the step's `env:` block):
|
|
32
|
+
# PR_BASE_SHA = github.event.pull_request.base.sha
|
|
33
|
+
# PR_HEAD_SHA = github.event.pull_request.head.sha
|
|
34
|
+
# MERGE_GROUP_BASE_SHA = github.event.merge_group.base_sha
|
|
35
|
+
# MERGE_GROUP_HEAD_SHA = github.event.merge_group.head_sha
|
|
36
|
+
# EVENT_NAME = github.event_name
|
|
37
|
+
# PUSH_BEFORE_SHA = github.event.before
|
|
38
|
+
# PUSH_HEAD_SHA = github.sha
|
|
39
|
+
#
|
|
40
|
+
# Outputs (set on the caller's shell):
|
|
41
|
+
# RESOLVED_EVENT_MODE = pull_request | merge_group | push | none
|
|
42
|
+
# RESOLVED_BASE_SHA = <base commit> (empty when mode = none)
|
|
43
|
+
# RESOLVED_HEAD_SHA = <head commit> (empty when mode = none)
|
|
44
|
+
#
|
|
45
|
+
# Each consumer applies its own shaping to the raw pair: gitleaks builds a
|
|
46
|
+
# `base..head` git-log range (`..` already excludes base-branch drift, so no
|
|
47
|
+
# explicit merge-base is needed); SAST needs a single `--baseline-commit`, and
|
|
48
|
+
# for pull_request derives the merge base of base..head (M8: base.sha is the
|
|
49
|
+
# live base-branch tip and drifts past the fork point once main advances),
|
|
50
|
+
# while for merge_group / push the base is already the exact fork point.
|
|
51
|
+
#
|
|
52
|
+
# `mode = none` means "no ranged base is resolvable" → the consumer falls back
|
|
53
|
+
# to a full-tree scan. This is reached by an absent context (queue no-op, or a
|
|
54
|
+
# branch-creation push whose `before` is the zero SHA / an unreachable commit).
|
|
55
|
+
|
|
56
|
+
resolve_diff_range() {
|
|
57
|
+
local zero_sha="0000000000000000000000000000000000000000"
|
|
58
|
+
RESOLVED_EVENT_MODE="none"
|
|
59
|
+
RESOLVED_BASE_SHA=""
|
|
60
|
+
RESOLVED_HEAD_SHA=""
|
|
61
|
+
|
|
62
|
+
if [ -n "${PR_BASE_SHA:-}" ] && [ -n "${PR_HEAD_SHA:-}" ]; then
|
|
63
|
+
# pull_request — the base-branch tip / PR head. Highest precedence so a
|
|
64
|
+
# caller that (unusually) exposes both PR and push context stays PR-scoped.
|
|
65
|
+
RESOLVED_EVENT_MODE="pull_request"
|
|
66
|
+
RESOLVED_BASE_SHA="${PR_BASE_SHA}"
|
|
67
|
+
RESOLVED_HEAD_SHA="${PR_HEAD_SHA}"
|
|
68
|
+
elif [ -n "${MERGE_GROUP_BASE_SHA:-}" ] && [ -n "${MERGE_GROUP_HEAD_SHA:-}" ]; then
|
|
69
|
+
# merge_group — the merge queue built base_sha..head_sha; head_sha is the
|
|
70
|
+
# temporary merge commit of the queued PR(s) on top of base_sha (the exact
|
|
71
|
+
# fork point, so no drift to correct for).
|
|
72
|
+
RESOLVED_EVENT_MODE="merge_group"
|
|
73
|
+
RESOLVED_BASE_SHA="${MERGE_GROUP_BASE_SHA}"
|
|
74
|
+
RESOLVED_HEAD_SHA="${MERGE_GROUP_HEAD_SHA}"
|
|
75
|
+
elif [ "${EVENT_NAME:-}" = "push" ] && [ -n "${PUSH_BEFORE_SHA:-}" ] && \
|
|
76
|
+
[ "${PUSH_BEFORE_SHA}" != "${zero_sha}" ] && \
|
|
77
|
+
git cat-file -e "${PUSH_BEFORE_SHA}^{commit}" 2>/dev/null; then
|
|
78
|
+
# push — the commits this push added (before..sha). `before` is the zero
|
|
79
|
+
# SHA on a branch-creation push, and may be unreachable after a
|
|
80
|
+
# force-push / shallow fetch; either case falls through to `none`.
|
|
81
|
+
RESOLVED_EVENT_MODE="push"
|
|
82
|
+
RESOLVED_BASE_SHA="${PUSH_BEFORE_SHA}"
|
|
83
|
+
RESOLVED_HEAD_SHA="${PUSH_HEAD_SHA}"
|
|
84
|
+
fi
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
resolve_diff_range
|
|
88
|
+
|
|
89
|
+
# When EXECUTED directly (not sourced) — e.g. by the unit test — echo the
|
|
90
|
+
# resolution as `KEY=value` lines so the truth table is assertable without a
|
|
91
|
+
# GitHub runner. `BASH_SOURCE[0] == $0` iff the file was run, not sourced.
|
|
92
|
+
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
|
93
|
+
printf 'RESOLVED_EVENT_MODE=%s\n' "${RESOLVED_EVENT_MODE}"
|
|
94
|
+
printf 'RESOLVED_BASE_SHA=%s\n' "${RESOLVED_BASE_SHA}"
|
|
95
|
+
printf 'RESOLVED_HEAD_SHA=%s\n' "${RESOLVED_HEAD_SHA}"
|
|
96
|
+
fi
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* resolve-diff-range.test.mjs — node:test suite for the single, event-agnostic
|
|
4
|
+
* base/head SHA derivation shared by every diff-scoped tier of pr-quality.yml
|
|
5
|
+
* (Story #314).
|
|
6
|
+
*
|
|
7
|
+
* The workflow's gitleaks and SAST resolvers both `source` this shell script,
|
|
8
|
+
* so its truth table IS the derived range each tier scans. This suite executes
|
|
9
|
+
* the script directly (it echoes `RESOLVED_*=…` lines when run rather than
|
|
10
|
+
* sourced) with per-event env fixtures and asserts the derived base/head for
|
|
11
|
+
* each of the three events plus the full-tree fallbacks — the "self-test
|
|
12
|
+
* showing the derived range for each event" half of the Story's Verify.
|
|
13
|
+
*
|
|
14
|
+
* Run: node scripts/resolve-diff-range.test.mjs (or `node --test scripts/`)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { test } from "node:test";
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const SCRIPT = join(HERE, "resolve-diff-range.sh");
|
|
25
|
+
// The repo root is a git repo, so the push-mode reachability guard
|
|
26
|
+
// (`git cat-file -e <before>`) can resolve a real ancestor commit.
|
|
27
|
+
const REPO_ROOT = join(HERE, "..");
|
|
28
|
+
|
|
29
|
+
// A real, reachable commit for exercising the push reachability guard, and its
|
|
30
|
+
// parent (also reachable) to stand in as the "before" SHA.
|
|
31
|
+
const HEAD_SHA = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
32
|
+
cwd: REPO_ROOT,
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
}).trim();
|
|
35
|
+
const PARENT_SHA = execFileSync("git", ["rev-parse", "HEAD~1"], {
|
|
36
|
+
cwd: REPO_ROOT,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
}).trim();
|
|
39
|
+
|
|
40
|
+
const ZERO_SHA = "0000000000000000000000000000000000000000";
|
|
41
|
+
// A syntactically-valid 40-hex SHA that is not an object in this repo.
|
|
42
|
+
const UNREACHABLE_SHA = "dead0000dead0000dead0000dead0000dead0000";
|
|
43
|
+
|
|
44
|
+
// Run the derivation with the given env and parse its `KEY=value` output.
|
|
45
|
+
function resolve(env) {
|
|
46
|
+
const out = execFileSync("bash", [SCRIPT], {
|
|
47
|
+
cwd: REPO_ROOT,
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
// Start from a clean slate so the ambient CI env (which may itself set
|
|
50
|
+
// GITHUB_* / EVENT_NAME) cannot leak into the fixture.
|
|
51
|
+
env: { PATH: process.env.PATH, ...env },
|
|
52
|
+
});
|
|
53
|
+
const parsed = {};
|
|
54
|
+
for (const line of out.split("\n")) {
|
|
55
|
+
const eq = line.indexOf("=");
|
|
56
|
+
if (eq === -1) continue;
|
|
57
|
+
parsed[line.slice(0, eq)] = line.slice(eq + 1);
|
|
58
|
+
}
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("pull_request: derives base.sha/head.sha directly", () => {
|
|
63
|
+
const r = resolve({
|
|
64
|
+
PR_BASE_SHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
65
|
+
PR_HEAD_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
66
|
+
EVENT_NAME: "pull_request",
|
|
67
|
+
});
|
|
68
|
+
assert.equal(r.RESOLVED_EVENT_MODE, "pull_request");
|
|
69
|
+
assert.equal(r.RESOLVED_BASE_SHA, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
|
70
|
+
assert.equal(r.RESOLVED_HEAD_SHA, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("merge_group: derives merge_group.base_sha/head_sha", () => {
|
|
74
|
+
const r = resolve({
|
|
75
|
+
MERGE_GROUP_BASE_SHA: "cccccccccccccccccccccccccccccccccccccccc",
|
|
76
|
+
MERGE_GROUP_HEAD_SHA: "dddddddddddddddddddddddddddddddddddddddd",
|
|
77
|
+
EVENT_NAME: "merge_group",
|
|
78
|
+
});
|
|
79
|
+
assert.equal(r.RESOLVED_EVENT_MODE, "merge_group");
|
|
80
|
+
assert.equal(r.RESOLVED_BASE_SHA, "cccccccccccccccccccccccccccccccccccccccc");
|
|
81
|
+
assert.equal(r.RESOLVED_HEAD_SHA, "dddddddddddddddddddddddddddddddddddddddd");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("pull_request takes precedence over a co-present merge_group context", () => {
|
|
85
|
+
const r = resolve({
|
|
86
|
+
PR_BASE_SHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
87
|
+
PR_HEAD_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
88
|
+
MERGE_GROUP_BASE_SHA: "cccccccccccccccccccccccccccccccccccccccc",
|
|
89
|
+
MERGE_GROUP_HEAD_SHA: "dddddddddddddddddddddddddddddddddddddddd",
|
|
90
|
+
});
|
|
91
|
+
assert.equal(r.RESOLVED_EVENT_MODE, "pull_request");
|
|
92
|
+
assert.equal(r.RESOLVED_BASE_SHA, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("merge_group takes precedence over a co-present push context", () => {
|
|
96
|
+
const r = resolve({
|
|
97
|
+
MERGE_GROUP_BASE_SHA: "cccccccccccccccccccccccccccccccccccccccc",
|
|
98
|
+
MERGE_GROUP_HEAD_SHA: "dddddddddddddddddddddddddddddddddddddddd",
|
|
99
|
+
EVENT_NAME: "push",
|
|
100
|
+
PUSH_BEFORE_SHA: PARENT_SHA,
|
|
101
|
+
PUSH_HEAD_SHA: HEAD_SHA,
|
|
102
|
+
});
|
|
103
|
+
assert.equal(r.RESOLVED_EVENT_MODE, "merge_group");
|
|
104
|
+
assert.equal(r.RESOLVED_BASE_SHA, "cccccccccccccccccccccccccccccccccccccccc");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("push: derives event.before/sha when before is a reachable commit", () => {
|
|
108
|
+
const r = resolve({
|
|
109
|
+
EVENT_NAME: "push",
|
|
110
|
+
PUSH_BEFORE_SHA: PARENT_SHA,
|
|
111
|
+
PUSH_HEAD_SHA: HEAD_SHA,
|
|
112
|
+
});
|
|
113
|
+
assert.equal(r.RESOLVED_EVENT_MODE, "push");
|
|
114
|
+
assert.equal(r.RESOLVED_BASE_SHA, PARENT_SHA);
|
|
115
|
+
assert.equal(r.RESOLVED_HEAD_SHA, HEAD_SHA);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("push with zero before (branch creation) → none/full-tree", () => {
|
|
119
|
+
const r = resolve({
|
|
120
|
+
EVENT_NAME: "push",
|
|
121
|
+
PUSH_BEFORE_SHA: ZERO_SHA,
|
|
122
|
+
PUSH_HEAD_SHA: HEAD_SHA,
|
|
123
|
+
});
|
|
124
|
+
assert.equal(r.RESOLVED_EVENT_MODE, "none");
|
|
125
|
+
assert.equal(r.RESOLVED_BASE_SHA, "");
|
|
126
|
+
assert.equal(r.RESOLVED_HEAD_SHA, "");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("push with an unreachable before (force-push/shallow) → none/full-tree", () => {
|
|
130
|
+
const r = resolve({
|
|
131
|
+
EVENT_NAME: "push",
|
|
132
|
+
PUSH_BEFORE_SHA: UNREACHABLE_SHA,
|
|
133
|
+
PUSH_HEAD_SHA: HEAD_SHA,
|
|
134
|
+
});
|
|
135
|
+
assert.equal(r.RESOLVED_EVENT_MODE, "none");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("no event context at all → none/full-tree (never a startup_failure)", () => {
|
|
139
|
+
const r = resolve({});
|
|
140
|
+
assert.equal(r.RESOLVED_EVENT_MODE, "none");
|
|
141
|
+
assert.equal(r.RESOLVED_BASE_SHA, "");
|
|
142
|
+
assert.equal(r.RESOLVED_HEAD_SHA, "");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("merge_group with only base_sha (partial context) → none", () => {
|
|
146
|
+
const r = resolve({
|
|
147
|
+
MERGE_GROUP_BASE_SHA: "cccccccccccccccccccccccccccccccccccccccc",
|
|
148
|
+
EVENT_NAME: "merge_group",
|
|
149
|
+
});
|
|
150
|
+
assert.equal(r.RESOLVED_EVENT_MODE, "none");
|
|
151
|
+
});
|