safegres 1.22.0 → 1.23.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 CHANGED
@@ -442,6 +442,13 @@ job summary, annotations and a sticky PR comment:
442
442
  upload-sarif: true # code scanning (needs security-events: write)
443
443
  ```
444
444
 
445
+ On a pull request it also measures the delta for you: it picks the base branch's most recent run
446
+ that *is* a sane comparison — succeeded, younger than 48 hours, and audited a commit in this
447
+ branch's merge-base history — downloads its report, and names the run it chose in the summary and
448
+ the comment (`actions: read`). "The last successful run" is not that: an unvalidated `gh run list
449
+ --limit 1` once handed a PR a 13.6-day-old baseline and billed it for ~150 merges it never
450
+ contained. Nothing qualifying is not a failure — no deltas, a line saying why, gates unchanged.
451
+
445
452
  It exposes `security-score`, `security-grade` and `perf-score` as step outputs — on a failing run
446
453
  too, which is when they get read. Everything else stays in the config file; see
447
454
  **[docs/reporting.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/reporting.md#the-action)**.
@@ -781,6 +788,7 @@ safegres lint # alias for audit, for a package.json script
781
788
  safegres perf # audit + the performance dimension (= audit --perf)
782
789
  safegres doctor # diagnose config, parser, connection, catalog visibility, exposure
783
790
  safegres eval # grade the auditor against a corpus with known answers
791
+ safegres baseline # CI: pick the report this run's delta is measured against
784
792
  safegres print-config # the resolved effective config (--explain for per-key provenance)
785
793
  ```
786
794
 
@@ -792,6 +800,7 @@ safegres print-config # the resolved effective config (--explain for per-key p
792
800
  | Scope | `--schemas`, `--exclude-schemas`, `--roles`, `--exclude-roles`, `--ignore-extensions`, `--audit-extension-owned` |
793
801
  | Performance | `--perf`, `--stats`, `--explain`, `--perf-baseline <f>`, `--write-perf-baseline <f>`, `--fail-on-new-perf` |
794
802
  | Reporting | `--format pretty\|json\|json-pretty\|markdown\|sarif`, `--out <dir>`, `--sarif-sources <dir>`, `--summary`/`-q`, `--verbose`, `--compare <f>`, `--compare-ref <label>`, `--write-snapshot <f>` |
803
+ | Delta provenance | `--compare-sha <sha>`, `--compare-run-id <id>`, `--compare-run-url <url>`, `--compare-age <age>`, `--compare-skipped <why>` |
795
804
  | Call graph | `--call-graph`, `--baseline <f>`, `--write-baseline <f>`, `--fail-on-new-boundaries` |
796
805
  | Gating | `--fail-on <severity>`, `--fail-on-score <n>`, `--fail-on-grade <g>`, `--fail-on-perf-score`, `--fail-on-perf-grade`, `--report-only` |
797
806
  | Misc | `--skip-ast`, `--no-color`, `--help`, `--version` |
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Which report a delta is measured against — chosen, validated, explained.
3
+ *
4
+ * A comparison is only as trustworthy as the artifact it subtracts, and CI
5
+ * picking that artifact is where it goes wrong. The shape everyone writes is a
6
+ * one-row query for the base branch's last successful run:
7
+ *
8
+ * gh run list --workflow ci.yaml --branch main --status success --limit 1
9
+ *
10
+ * `--limit 1` is `per_page=1`, so one API row decides the baseline with nothing
11
+ * checking it and nothing printing which row it was. Observed cost, on the PR
12
+ * this module was written for: that query answered with a `main` run 13.6 days
13
+ * and ~150 successful runs stale, and a PR touching no SQL at all was reported
14
+ * as `Δ vs main: ▼ −0.8, findings 2877 → 3323` — every merge since. A finished,
15
+ * correct run existed; the artifacts had not expired. An unvalidated answer was
16
+ * simply trusted.
17
+ *
18
+ * The fix is not a cleverer query. This takes several candidates and picks the
19
+ * newest that survives three questions:
20
+ *
21
+ * 1. did it succeed, and is its report artifact still downloadable?
22
+ * 2. is it younger than `maxAgeMs`?
23
+ * 3. is its head commit an ancestor of this branch's merge base with the base?
24
+ *
25
+ * (3) is what makes the delta mean "what this branch did": a baseline from the
26
+ * tip of the base branch *after* the branch point charges the branch for merges
27
+ * it never contained, and one from rewritten or unrelated history is not
28
+ * comparable at all. (2) catches a stale baseline that is a legitimate ancestor.
29
+ *
30
+ * No acceptable candidate is not a failure — it is a report with no deltas and
31
+ * a stated reason (`--compare-skipped`). The absolute grade gate and the perf
32
+ * ratchet never read the comparison, so they still apply.
33
+ *
34
+ * This module is pure and provider-agnostic: ancestry and artifact download
35
+ * arrive as callbacks, which is what makes the decision testable without a
36
+ * network. `./github` supplies them from GitHub Actions.
37
+ */
38
+ /** One run of the base branch, as a provider lists them (newest first). */
39
+ export interface BaselineCandidate {
40
+ /** Provider's id for the run, as it appears in a URL. */
41
+ runId: string;
42
+ /** Commit the run audited. */
43
+ headSha: string;
44
+ /** ISO 8601. When the run finished, or started if that is all there is. */
45
+ finishedAt: string;
46
+ /** `success` qualifies; anything else (including in-progress) does not. */
47
+ conclusion: string | null;
48
+ }
49
+ export interface SelectBaselineOptions {
50
+ /** The branch point this comparison must be relative to. */
51
+ mergeBaseSha: string;
52
+ /** Is `sha` in the merge base's history? */
53
+ isAncestor: (sha: string) => boolean;
54
+ /** Fetch a candidate's report; null when the artifact is gone. */
55
+ download: (candidate: BaselineCandidate) => string | null;
56
+ now?: number;
57
+ maxAgeMs?: number;
58
+ }
59
+ export interface RejectedCandidate {
60
+ runId: string;
61
+ headSha: string;
62
+ reason: string;
63
+ }
64
+ export interface ChosenBaseline extends BaselineCandidate {
65
+ /** Age at selection time, in milliseconds. */
66
+ ageMs: number;
67
+ /** Path to the downloaded report. */
68
+ path: string;
69
+ }
70
+ export interface BaselineSelection {
71
+ chosen: ChosenBaseline | null;
72
+ rejected: RejectedCandidate[];
73
+ /** One line for the job summary when `chosen` is null. */
74
+ reason: string | null;
75
+ }
76
+ /**
77
+ * How stale a baseline may be by default: 48 hours.
78
+ *
79
+ * The window has to be wider than the gap between two base-branch runs, or a
80
+ * branch loses its delta for no reason. Those runs are per merge, so the gap is
81
+ * activity rather than a schedule: on the repository this was measured against,
82
+ * `main` averaged well under an hour between successful runs on a working day
83
+ * and went ~24 hours over a weekend. 48 hours clears the widest observed quiet
84
+ * stretch with room to spare, while still being a *comparison* — anything older
85
+ * describes a schema several merges removed from the branch point.
86
+ */
87
+ export declare const MAX_BASELINE_AGE_MS: number;
88
+ /**
89
+ * How many runs to consider by default.
90
+ *
91
+ * More than one on principle — trusting a single row is the bug — and enough
92
+ * that an in-progress tip, a cancelled run and a couple of expired artifacts
93
+ * can all be skipped without falling out of the age window. Beyond ~20 the
94
+ * candidates are older than the window anyway, so they cost a request to
95
+ * reject.
96
+ */
97
+ export declare const CANDIDATE_LIMIT = 20;
98
+ /**
99
+ * The newest candidate that is a sane comparison, and why the others were not.
100
+ *
101
+ * `isAncestor` and `download` are I/O in CI and stubs in tests, and are only
102
+ * asked about a candidate that already passed the cheap checks.
103
+ */
104
+ export declare function selectBaseline(candidates: BaselineCandidate[], options: SelectBaselineOptions): BaselineSelection;
package/ci/baseline.js ADDED
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * Which report a delta is measured against — chosen, validated, explained.
4
+ *
5
+ * A comparison is only as trustworthy as the artifact it subtracts, and CI
6
+ * picking that artifact is where it goes wrong. The shape everyone writes is a
7
+ * one-row query for the base branch's last successful run:
8
+ *
9
+ * gh run list --workflow ci.yaml --branch main --status success --limit 1
10
+ *
11
+ * `--limit 1` is `per_page=1`, so one API row decides the baseline with nothing
12
+ * checking it and nothing printing which row it was. Observed cost, on the PR
13
+ * this module was written for: that query answered with a `main` run 13.6 days
14
+ * and ~150 successful runs stale, and a PR touching no SQL at all was reported
15
+ * as `Δ vs main: ▼ −0.8, findings 2877 → 3323` — every merge since. A finished,
16
+ * correct run existed; the artifacts had not expired. An unvalidated answer was
17
+ * simply trusted.
18
+ *
19
+ * The fix is not a cleverer query. This takes several candidates and picks the
20
+ * newest that survives three questions:
21
+ *
22
+ * 1. did it succeed, and is its report artifact still downloadable?
23
+ * 2. is it younger than `maxAgeMs`?
24
+ * 3. is its head commit an ancestor of this branch's merge base with the base?
25
+ *
26
+ * (3) is what makes the delta mean "what this branch did": a baseline from the
27
+ * tip of the base branch *after* the branch point charges the branch for merges
28
+ * it never contained, and one from rewritten or unrelated history is not
29
+ * comparable at all. (2) catches a stale baseline that is a legitimate ancestor.
30
+ *
31
+ * No acceptable candidate is not a failure — it is a report with no deltas and
32
+ * a stated reason (`--compare-skipped`). The absolute grade gate and the perf
33
+ * ratchet never read the comparison, so they still apply.
34
+ *
35
+ * This module is pure and provider-agnostic: ancestry and artifact download
36
+ * arrive as callbacks, which is what makes the decision testable without a
37
+ * network. `./github` supplies them from GitHub Actions.
38
+ */
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.CANDIDATE_LIMIT = exports.MAX_BASELINE_AGE_MS = void 0;
41
+ exports.selectBaseline = selectBaseline;
42
+ const compare_1 = require("../report/compare");
43
+ /**
44
+ * How stale a baseline may be by default: 48 hours.
45
+ *
46
+ * The window has to be wider than the gap between two base-branch runs, or a
47
+ * branch loses its delta for no reason. Those runs are per merge, so the gap is
48
+ * activity rather than a schedule: on the repository this was measured against,
49
+ * `main` averaged well under an hour between successful runs on a working day
50
+ * and went ~24 hours over a weekend. 48 hours clears the widest observed quiet
51
+ * stretch with room to spare, while still being a *comparison* — anything older
52
+ * describes a schema several merges removed from the branch point.
53
+ */
54
+ exports.MAX_BASELINE_AGE_MS = 48 * 60 * 60 * 1000;
55
+ /**
56
+ * How many runs to consider by default.
57
+ *
58
+ * More than one on principle — trusting a single row is the bug — and enough
59
+ * that an in-progress tip, a cancelled run and a couple of expired artifacts
60
+ * can all be skipped without falling out of the age window. Beyond ~20 the
61
+ * candidates are older than the window anyway, so they cost a request to
62
+ * reject.
63
+ */
64
+ exports.CANDIDATE_LIMIT = 20;
65
+ const short = (sha) => (sha || '').slice(0, 9);
66
+ /**
67
+ * The newest candidate that is a sane comparison, and why the others were not.
68
+ *
69
+ * `isAncestor` and `download` are I/O in CI and stubs in tests, and are only
70
+ * asked about a candidate that already passed the cheap checks.
71
+ */
72
+ function selectBaseline(candidates, options) {
73
+ const { mergeBaseSha, isAncestor, download, now = Date.now(), maxAgeMs = exports.MAX_BASELINE_AGE_MS } = options;
74
+ const rejected = [];
75
+ const reject = (run, why) => {
76
+ rejected.push({ runId: run.runId, headSha: run.headSha, reason: why });
77
+ };
78
+ for (const run of candidates) {
79
+ if (run.conclusion !== 'success') {
80
+ reject(run, `conclusion ${run.conclusion || 'pending'}`);
81
+ continue;
82
+ }
83
+ const ageMs = now - Date.parse(run.finishedAt);
84
+ if (!(ageMs >= 0)) {
85
+ reject(run, `unreadable timestamp ${run.finishedAt}`);
86
+ continue;
87
+ }
88
+ if (ageMs > maxAgeMs) {
89
+ reject(run, `${(0, compare_1.formatAge)(ageMs)} old, older than the ${(0, compare_1.formatAge)(maxAgeMs)} window`);
90
+ continue;
91
+ }
92
+ if (!isAncestor(run.headSha)) {
93
+ reject(run, `${short(run.headSha)} is not an ancestor of merge base ${short(mergeBaseSha)}`);
94
+ continue;
95
+ }
96
+ const path = download(run);
97
+ if (!path) {
98
+ reject(run, 'no downloadable report artifact');
99
+ continue;
100
+ }
101
+ return { chosen: { ...run, ageMs, path }, rejected, reason: null };
102
+ }
103
+ return {
104
+ chosen: null,
105
+ rejected,
106
+ reason: candidates.length > 0
107
+ ? `no run within ${(0, compare_1.formatAge)(maxAgeMs)} whose head is an ancestor of merge base `
108
+ + `${short(mergeBaseSha)} (${rejected.length} candidate(s) rejected)`
109
+ : 'no runs to compare against'
110
+ };
111
+ }
package/ci/github.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The GitHub Actions half of baseline selection: run discovery, merge base,
3
+ * artifact download. Everything provider-shaped lives here, so `./baseline`
4
+ * stays a pure decision and the report layer never learns what a workflow run
5
+ * is.
6
+ *
7
+ * The I/O goes through the `gh` CLI, which every GitHub-hosted runner has and
8
+ * which already handles pagination, auth from `GITHUB_TOKEN`, and unzipping an
9
+ * artifact. Reaching the API needs `permissions: actions: read`.
10
+ */
11
+ import { type BaselineSelection } from './baseline';
12
+ export interface GithubBaselineOptions {
13
+ /** `owner/repo`. Default `$GITHUB_REPOSITORY`. */
14
+ repo?: string;
15
+ /** Branch whose runs are candidates — the PR's base. Default from the event. */
16
+ base?: string;
17
+ /** The branch tip to find the merge base of. Default from the event. */
18
+ headSha?: string;
19
+ /** Workflow file whose runs produced the reports. Default the current one. */
20
+ workflow?: string;
21
+ /** Artifact holding `safegres.json`. Default `safegres-reports`. */
22
+ artifact?: string;
23
+ /** Where to unpack it. Default `previous`. */
24
+ dir?: string;
25
+ maxAgeMs?: number;
26
+ candidateLimit?: number;
27
+ now?: number;
28
+ /** Where the selection log goes. Default stdout. */
29
+ log?: (line: string) => void;
30
+ }
31
+ /** What the caller needs to turn a selection into `safegres audit` flags. */
32
+ export interface GithubBaselineResult extends BaselineSelection {
33
+ base: string;
34
+ mergeBaseSha: string;
35
+ /** `https://github.com/<repo>/actions/runs/<id>`, when one was chosen. */
36
+ runUrl?: string;
37
+ /** Pre-formatted age of the chosen run. */
38
+ age?: string;
39
+ }
40
+ /**
41
+ * Select a baseline report from the base branch's recent runs.
42
+ *
43
+ * Throws only on a broken invocation (no repository, no PR context, `gh`
44
+ * missing). A candidate that cannot be used is rejected, not fatal: the caller
45
+ * renders the report without deltas and says why.
46
+ */
47
+ export declare function selectGithubBaseline(options?: GithubBaselineOptions): GithubBaselineResult;
package/ci/github.js ADDED
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ /**
3
+ * The GitHub Actions half of baseline selection: run discovery, merge base,
4
+ * artifact download. Everything provider-shaped lives here, so `./baseline`
5
+ * stays a pure decision and the report layer never learns what a workflow run
6
+ * is.
7
+ *
8
+ * The I/O goes through the `gh` CLI, which every GitHub-hosted runner has and
9
+ * which already handles pagination, auth from `GITHUB_TOKEN`, and unzipping an
10
+ * artifact. Reaching the API needs `permissions: actions: read`.
11
+ */
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.selectGithubBaseline = selectGithubBaseline;
47
+ const child_process_1 = require("child_process");
48
+ const fs = __importStar(require("fs"));
49
+ const path = __importStar(require("path"));
50
+ const compare_1 = require("../report/compare");
51
+ const baseline_1 = require("./baseline");
52
+ const short = (sha) => sha.slice(0, 9);
53
+ const gh = (args) => (0, child_process_1.execFileSync)('gh', args, { encoding: 'utf8' });
54
+ /**
55
+ * `gh run` needs `-R` explicitly: the workspace may be an unpacked artifact
56
+ * with no `.git` for it to infer the repository from. `gh api` carries the
57
+ * repository in the path and rejects the flag.
58
+ */
59
+ const ghRun = (repo, args) => gh(['run', ...args, '-R', repo]);
60
+ /** The workflow file of the run this is called from, e.g. `run-tests.yaml`. */
61
+ function currentWorkflow() {
62
+ const ref = process.env.GITHUB_WORKFLOW_REF; // owner/repo/.github/workflows/x.yml@refs/…
63
+ if (!ref)
64
+ return undefined;
65
+ const file = ref.split('@')[0].split('/').pop();
66
+ return file || undefined;
67
+ }
68
+ /** The PR's head sha, from the event payload the runner wrote to disk. */
69
+ function eventHeadSha() {
70
+ const file = process.env.GITHUB_EVENT_PATH;
71
+ if (!file || !fs.existsSync(file))
72
+ return undefined;
73
+ const event = JSON.parse(fs.readFileSync(file, 'utf8'));
74
+ return event.pull_request?.head?.sha;
75
+ }
76
+ function eventBaseRef() {
77
+ const file = process.env.GITHUB_EVENT_PATH;
78
+ if (!file || !fs.existsSync(file))
79
+ return undefined;
80
+ const event = JSON.parse(fs.readFileSync(file, 'utf8'));
81
+ return event.pull_request?.base?.ref ?? process.env.GITHUB_BASE_REF;
82
+ }
83
+ /**
84
+ * The path, if it holds a report a delta can be measured against — an artifact
85
+ * that exists but is truncated or from an incompatible version would otherwise
86
+ * fail the audit at compare time, which is the one thing a baseline lookup must
87
+ * never do.
88
+ */
89
+ function readableReport(file, log) {
90
+ if (!fs.existsSync(file)) {
91
+ log(`artifact holds no ${path.basename(file)}`);
92
+ return null;
93
+ }
94
+ try {
95
+ const report = JSON.parse(fs.readFileSync(file, 'utf8'));
96
+ if (report.summary === undefined)
97
+ throw new Error('no summary');
98
+ return file;
99
+ }
100
+ catch (err) {
101
+ log(`unusable report — ${err.message}`);
102
+ return null;
103
+ }
104
+ }
105
+ /**
106
+ * Select a baseline report from the base branch's recent runs.
107
+ *
108
+ * Throws only on a broken invocation (no repository, no PR context, `gh`
109
+ * missing). A candidate that cannot be used is rejected, not fatal: the caller
110
+ * renders the report without deltas and says why.
111
+ */
112
+ function selectGithubBaseline(options = {}) {
113
+ const log = options.log ?? ((line) => console.log(line));
114
+ const repo = options.repo ?? process.env.GITHUB_REPOSITORY;
115
+ const base = options.base ?? eventBaseRef();
116
+ const headSha = options.headSha ?? eventHeadSha() ?? process.env.GITHUB_SHA;
117
+ const workflow = options.workflow ?? currentWorkflow();
118
+ const artifact = options.artifact ?? 'safegres-reports';
119
+ const dir = path.resolve(options.dir ?? 'previous');
120
+ if (!repo)
121
+ throw new Error('GITHUB_REPOSITORY is not set: pass --repo owner/repo');
122
+ if (!base)
123
+ throw new Error('no base branch: pass --base (this is a pull-request-only feature)');
124
+ if (!headSha)
125
+ throw new Error('no head commit: pass --head <sha>');
126
+ if (!workflow)
127
+ throw new Error('no workflow: pass --workflow <file.yaml>');
128
+ // The branch point, from the API rather than `git merge-base`: the audit job
129
+ // may not have checked the repository out, and a shallow clone has no history.
130
+ const mergeBaseSha = JSON.parse(gh(['api', `repos/${repo}/compare/${base}...${headSha}`])).merge_base_commit.sha;
131
+ log(`merge base with ${base}: ${short(mergeBaseSha)}`);
132
+ const runs = JSON.parse(ghRun(repo, [
133
+ 'list',
134
+ '--workflow',
135
+ workflow,
136
+ '--branch',
137
+ base,
138
+ '--limit',
139
+ String(options.candidateLimit ?? baseline_1.CANDIDATE_LIMIT),
140
+ '--json',
141
+ 'databaseId,headSha,createdAt,updatedAt,conclusion'
142
+ ]));
143
+ const candidates = runs.map((r) => ({
144
+ runId: String(r.databaseId),
145
+ headSha: r.headSha,
146
+ // `updatedAt` is when the run concluded; `createdAt` until GitHub records it.
147
+ finishedAt: r.updatedAt || r.createdAt,
148
+ conclusion: r.conclusion
149
+ }));
150
+ const ancestry = new Map();
151
+ const isAncestor = (sha) => {
152
+ if (!ancestry.has(sha)) {
153
+ // `ahead` = the merge base is ahead of the candidate, i.e. the candidate
154
+ // is in its history; `identical` = the baseline *is* the branch point, the
155
+ // ideal case. `behind`/`diverged` are the ones to refuse.
156
+ const status = JSON.parse(gh(['api', `repos/${repo}/compare/${sha}...${mergeBaseSha}`])).status;
157
+ ancestry.set(sha, status === 'ahead' || status === 'identical');
158
+ }
159
+ return ancestry.get(sha) === true;
160
+ };
161
+ const download = (run) => {
162
+ // Emptied first: successive candidates unpack into the same directory, and a
163
+ // leftover report from a rejected one would make the next candidate look
164
+ // usable and put the wrong run's numbers in the delta.
165
+ fs.rmSync(dir, { recursive: true, force: true });
166
+ try {
167
+ ghRun(repo, ['download', run.runId, '-n', artifact, '-D', dir]);
168
+ }
169
+ catch (err) {
170
+ // The one ignorable failure here: an expired or never-uploaded artifact is
171
+ // a rejected candidate, not a broken job. Anything else (auth, network)
172
+ // fails the same way on the next candidate and surfaces as "no baseline".
173
+ log(` ${run.runId}: artifact download failed — ${String(err.message).trim()}`);
174
+ return null;
175
+ }
176
+ return readableReport(path.join(dir, 'safegres.json'), (why) => log(` ${run.runId}: ${why}`));
177
+ };
178
+ const selection = (0, baseline_1.selectBaseline)(candidates, {
179
+ mergeBaseSha,
180
+ isAncestor,
181
+ download,
182
+ ...(options.now !== undefined && { now: options.now }),
183
+ maxAgeMs: options.maxAgeMs ?? baseline_1.MAX_BASELINE_AGE_MS
184
+ });
185
+ for (const r of selection.rejected) {
186
+ log(`rejected ${r.runId} (${short(r.headSha)}): ${r.reason}`);
187
+ }
188
+ if (!selection.chosen) {
189
+ log(`no comparison baseline: ${selection.reason}`);
190
+ return { ...selection, base, mergeBaseSha };
191
+ }
192
+ const age = (0, compare_1.formatAge)(selection.chosen.ageMs);
193
+ const runUrl = `${process.env.GITHUB_SERVER_URL ?? 'https://github.com'}/${repo}/actions/runs/${selection.chosen.runId}`;
194
+ log(`baseline: run ${selection.chosen.runId} ${base}@${short(selection.chosen.headSha)}, `
195
+ + `${age} old (${selection.chosen.finishedAt})`);
196
+ return { ...selection, base, mergeBaseSha, runUrl, age };
197
+ }
package/cli/audit.js CHANGED
@@ -139,6 +139,14 @@ Comparison with a previous run (what changed, not just what is):
139
139
  snapshot written by --write-snapshot
140
140
  --compare-ref <label> How to name the previous run in the report
141
141
  (e.g. "main", a commit sha). Default "previous run"
142
+ --compare-sha <sha> Commit the previous run was produced from
143
+ --compare-run-id <id> CI run that produced it
144
+ --compare-run-url <url> Link to that run
145
+ --compare-age <age> How old it is, pre-formatted ("10.2 hours").
146
+ Defaults to the age of its generatedAt
147
+ --compare-skipped <why> There is no baseline and this is the reason, shown
148
+ in place of the delta so an absent delta does not
149
+ read as "nothing changed"
142
150
  --write-snapshot <file> Write the aggregate slice of this run (scores,
143
151
  counts, per-rule counts) for a later --compare,
144
152
  when keeping the whole report is too much
@@ -325,8 +333,14 @@ exports.default = async (argv, _prompter, _options) => {
325
333
  }
326
334
  if (typeof argv['compare-ref'] === 'string')
327
335
  previous.ref = argv['compare-ref'];
336
+ const provenance = baselineProvenance(argv);
337
+ if (provenance)
338
+ previous.provenance = { ...previous.provenance, ...provenance };
328
339
  report.comparison = (0, compare_1.compareReports)(previous, report);
329
340
  }
341
+ else if (typeof argv['compare-skipped'] === 'string' && argv['compare-skipped'] !== '') {
342
+ report.comparisonSkipped = argv['compare-skipped'];
343
+ }
330
344
  if (callGraphBaseline !== undefined && report.callGraph) {
331
345
  let raw;
332
346
  try {
@@ -516,6 +530,21 @@ exports.default = async (argv, _prompter, _options) => {
516
530
  if (failed)
517
531
  log.warn('--report-only: gates failed, exiting 0');
518
532
  };
533
+ /**
534
+ * Where the baseline came from, as the provider reported it. A provider is
535
+ * whatever chose the file — a CI adapter, a script, a person — so every field
536
+ * is optional and none of them mean anything to safegres beyond being printed.
537
+ */
538
+ function baselineProvenance(argv) {
539
+ const str = (flag) => typeof argv[flag] === 'string' && argv[flag] !== '' ? argv[flag] : undefined;
540
+ const provenance = {
541
+ ...(str('compare-sha') && { sha: str('compare-sha') }),
542
+ ...(str('compare-run-id') && { runId: str('compare-run-id') }),
543
+ ...(str('compare-run-url') && { runUrl: str('compare-run-url') }),
544
+ ...(str('compare-age') && { age: str('compare-age') })
545
+ };
546
+ return Object.keys(provenance).length > 0 ? provenance : undefined;
547
+ }
519
548
  /** Write an output file, creating its directory: CI should not have to mkdir. */
520
549
  function writeOut(file, contents) {
521
550
  fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
@@ -0,0 +1,3 @@
1
+ import { CLIOptions, Inquirerer, ParsedArgs } from 'inquirerer';
2
+ declare const _default: (argv: ParsedArgs, _prompter: Inquirerer, _options: CLIOptions) => Promise<void>;
3
+ export default _default;
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const logger_1 = require("@pgpmjs/logger");
4
+ const fs_1 = require("fs");
5
+ const baseline_1 = require("../ci/baseline");
6
+ const github_1 = require("../ci/github");
7
+ const log = new logger_1.Logger('safegres');
8
+ const usage = `
9
+ safegres baseline — choose the report a delta is measured against
10
+
11
+ safegres baseline --provider github [OPTIONS]
12
+
13
+ Finds the base branch's most recent run that is a sane comparison — succeeded,
14
+ young enough, and audited a commit in this branch's merge-base history — and
15
+ downloads its report. When nothing qualifies it says why and exits 0: the audit
16
+ then runs with no deltas, and the absolute gates still apply.
17
+
18
+ The result is written to $GITHUB_OUTPUT when the runner provides one, as
19
+ compare / compare-ref / compare-sha / compare-run-id / compare-run-url /
20
+ compare-age / compare-skipped — the same names \`safegres audit\` takes as
21
+ flags, so the next step is \`--compare "\${{ steps.<id>.outputs.compare }}"\`.
22
+
23
+ Options:
24
+ --provider <name> Where the runs live. Only "github" today
25
+ --repo <owner/repo> Default $GITHUB_REPOSITORY
26
+ --base <ref> Base branch whose runs are candidates
27
+ (default: the pull request's base)
28
+ --head <sha> Branch tip to take the merge base of
29
+ (default: the pull request's head)
30
+ --workflow <file> Workflow file that produced the reports
31
+ (default: the workflow this runs in)
32
+ --artifact <name> Artifact holding safegres.json
33
+ (default: safegres-reports)
34
+ --dir <dir> Where to unpack it (default: previous)
35
+ --max-age-hours <n> Staleness limit (default: ${baseline_1.MAX_BASELINE_AGE_MS / 3600000})
36
+ --candidates <n> Runs to consider (default: ${baseline_1.CANDIDATE_LIMIT})
37
+ --json Print the selection as JSON
38
+ --help, -h Show this help message
39
+
40
+ Needs \`permissions: actions: read\` to list runs and download artifacts.
41
+ `;
42
+ /** `key=value` into a GitHub Actions file, or stdout when running locally. */
43
+ function emit(file, key, value) {
44
+ const line = `${key}=${value}`;
45
+ if (file)
46
+ (0, fs_1.appendFileSync)(file, `${line}\n`);
47
+ else
48
+ process.stdout.write(`(${key}) ${value}\n`);
49
+ }
50
+ function publish(result) {
51
+ const file = process.env.GITHUB_OUTPUT;
52
+ if (result.chosen) {
53
+ emit(file, 'compare', result.chosen.path);
54
+ emit(file, 'compare-ref', result.base);
55
+ emit(file, 'compare-sha', result.chosen.headSha);
56
+ emit(file, 'compare-run-id', result.chosen.runId);
57
+ if (result.runUrl)
58
+ emit(file, 'compare-run-url', result.runUrl);
59
+ if (result.age)
60
+ emit(file, 'compare-age', result.age);
61
+ }
62
+ else {
63
+ emit(file, 'compare-skipped', result.reason ?? 'no baseline');
64
+ }
65
+ }
66
+ exports.default = async (argv, _prompter, _options) => {
67
+ if (argv.help || argv.h) {
68
+ process.stdout.write(usage);
69
+ return;
70
+ }
71
+ const provider = typeof argv.provider === 'string' ? argv.provider : 'github';
72
+ if (provider !== 'github') {
73
+ log.error(`unknown --provider ${provider} (only "github" is implemented)`);
74
+ process.exit(2);
75
+ }
76
+ const hours = Number(argv['max-age-hours']);
77
+ const candidates = Number(argv.candidates);
78
+ let result;
79
+ try {
80
+ result = (0, github_1.selectGithubBaseline)({
81
+ ...(typeof argv.repo === 'string' && { repo: argv.repo }),
82
+ ...(typeof argv.base === 'string' && { base: argv.base }),
83
+ ...(typeof argv.head === 'string' && { headSha: argv.head }),
84
+ ...(typeof argv.workflow === 'string' && { workflow: argv.workflow }),
85
+ ...(typeof argv.artifact === 'string' && { artifact: argv.artifact }),
86
+ ...(typeof argv.dir === 'string' && { dir: argv.dir }),
87
+ ...(Number.isFinite(hours) && hours > 0 && { maxAgeMs: hours * 3600000 }),
88
+ ...(Number.isFinite(candidates) && candidates > 0 && { candidateLimit: candidates })
89
+ });
90
+ }
91
+ catch (err) {
92
+ // Fail-soft, loudly: a missing PR context, a token without `actions: read`
93
+ // or a `gh` that is not installed must not fail an audit that is perfectly
94
+ // able to run without deltas — but the reason travels into the report as
95
+ // the skip message rather than being swallowed.
96
+ const message = err.message.trim();
97
+ log.error(`no comparison baseline: ${message}`);
98
+ publish({ chosen: null, rejected: [], reason: message, base: '', mergeBaseSha: '' });
99
+ return;
100
+ }
101
+ publish(result);
102
+ if (argv.json === true)
103
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
104
+ };