@scalequality/check 0.5.5 → 0.5.6
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 +45 -2
- package/bin/check.mjs +237 -45
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,13 +19,26 @@ and **Turn on continuous measurement**. Store the token as a CI secret named
|
|
|
19
19
|
| `--token` | `SQ_TOKEN` | — | Your SQ token (required) |
|
|
20
20
|
| `--project` | `SQ_PROJECT` | — | ScaleQuality project id (required) |
|
|
21
21
|
| `--host` | `SQ_HOST` | `https://app.scalequality.io` | API host |
|
|
22
|
-
| `--timeout` | — | `600` | Max seconds to wait for the measurement |
|
|
22
|
+
| `--timeout` | — | `600` | Max seconds to wait for the measurement, and separately for a measurement of the same project already running |
|
|
23
23
|
| `--json` | — | off | Print the raw result as JSON |
|
|
24
24
|
| `--exclude <path>` | — | — | Exclude a generated file/directory by literal checkout-relative path; repeatable |
|
|
25
|
+
| `--strict` | `SQ_STRICT` | off | Fail the step (exit `2`) when the run could not be measured |
|
|
25
26
|
| `--dry-run` | — | off | Measure locally and validate the package without uploading; no token needed |
|
|
26
27
|
| `--artifact <path>` | `SQ_ARTIFACTS` | — | Scan a binary this job built for its components, licenses and CVEs (needs trivy); repeatable |
|
|
27
28
|
|
|
28
|
-
Exit codes:
|
|
29
|
+
Exit codes:
|
|
30
|
+
|
|
31
|
+
| Code | When |
|
|
32
|
+
|------|------|
|
|
33
|
+
| `0` | Measured and passed, measured in track-only mode, or **NOT MEASURED** (see below) |
|
|
34
|
+
| `1` | Measured: below your cutoff, or a new dependency with a license your organization blocks |
|
|
35
|
+
| `2` | NOT MEASURED with `--strict`, or `--dry-run` could not prepare the package |
|
|
36
|
+
|
|
37
|
+
Only a measurement fails the build. When a run cannot be measured, the step says
|
|
38
|
+
**NOT MEASURED** in the log, in a warning on the run (GitHub Actions, Azure
|
|
39
|
+
Pipelines), in the job summary and in `--json` (`"measured": false`, with
|
|
40
|
+
`origin` and `reason`), never says the gate passed, and exits `0`. Pass `--strict`
|
|
41
|
+
to fail the step instead.
|
|
29
42
|
|
|
30
43
|
## CI examples
|
|
31
44
|
|
|
@@ -225,3 +238,33 @@ licenses and their CVEs join the measurement. Needs trivy on PATH, like
|
|
|
225
238
|
- run: npx @scalequality/check --artifact target/app.war
|
|
226
239
|
env: { SQ_TOKEN: ${{ secrets.SQ_TOKEN }}, SQ_PROJECT: ${{ vars.SQ_PROJECT }} }
|
|
227
240
|
```
|
|
241
|
+
|
|
242
|
+
## 0.5.6: only a measurement fails the build
|
|
243
|
+
|
|
244
|
+
Until 0.5.5 every run that could not be measured exited `2`, and a pipeline that
|
|
245
|
+
runs the step as is failed on it: ScaleQuality unreachable or answering 5xx, a
|
|
246
|
+
timeout, a failed measurement, an expired token, a lapsed plan, continuous turned
|
|
247
|
+
off, or a pull request from a fork or Dependabot that gets no secrets. Now only a
|
|
248
|
+
measured verdict fails the step (`1`). Everything else exits `0` and says
|
|
249
|
+
**NOT MEASURED**, with who has to act:
|
|
250
|
+
|
|
251
|
+
| `origin` | `reason` | What happened |
|
|
252
|
+
|----------|----------|---------------|
|
|
253
|
+
| `scalequality` | `unreachable`, `server_error`, `auth_unavailable`, `server_rejected` | The upload did not reach a working ScaleQuality (after three short retries for network errors, 429 and 5xx) |
|
|
254
|
+
| `scalequality` | `measurement_already_running` | A measurement of the same project kept running for longer than `--timeout` (the check waits for it first) |
|
|
255
|
+
| `scalequality` | `timeout`, `status_unavailable`, `run_not_found`, `no_run_id` | The measurement did not report back in time |
|
|
256
|
+
| `scalequality` | `measurement_failed` | The engine failed; `failureReason` says why |
|
|
257
|
+
| `scalequality` | `no_verdict` | The measurement finished without a score to hold the cutoff against (exited `1` before) |
|
|
258
|
+
| `scalequality` | `protocol_unconfirmed`, `durability_unconfirmed` | The server did not confirm the report this checkout sent |
|
|
259
|
+
| `configuration` | `missing_credentials` | No `--token`/`--project` (secrets do not reach fork or Dependabot pull requests) |
|
|
260
|
+
| `configuration` | `invalid_token`, `token_expired` | The token was refused or expired (tokens default to 90 days) |
|
|
261
|
+
| `configuration` | `subscription_required` | No active plan with continuous measurement |
|
|
262
|
+
| `configuration` | `continuous_not_enabled` | Continuous measurement is turned off for the project |
|
|
263
|
+
| `configuration` | `project_not_found`, `invalid_option` | The project id or an `--exclude` is wrong |
|
|
264
|
+
| `checkout` | `durability_not_collected`, `checkout_not_packaged`, `head_changed` | This job's checkout could not be measured or packaged (a shallow clone, for example); nothing was uploaded |
|
|
265
|
+
|
|
266
|
+
Each request has its own deadline, so a host that accepts the connection and never
|
|
267
|
+
answers no longer holds the job until the CI kills it. `--strict` (or
|
|
268
|
+
`SQ_STRICT=true`) restores exit `2` for all of the above, for teams that prefer to
|
|
269
|
+
fail closed. A license your organization blocks and a score below the cutoff still
|
|
270
|
+
exit `1`, with or without it.
|
package/bin/check.mjs
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// ScaleQuality continuous check. Uploads THIS CI checkout to ScaleQuality (re-measures
|
|
3
|
-
// the code the runner already has, with a local 90-day durability report) and exits
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// the code the runner already has, with a local 90-day durability report) and exits 1
|
|
4
|
+
// when the measurement says the gate did not pass (below the configured cutoff, or a
|
|
5
|
+
// license the organization blocks), so a CI step can fail the build. Only a measured
|
|
6
|
+
// verdict fails it: a run ScaleQuality could not measure exits 0 with a NOT MEASURED
|
|
7
|
+
// warning, unless --strict asks to fail closed. Pure Node 18+ (global
|
|
8
|
+
// fetch/FormData/Blob), no npm dependencies.
|
|
6
9
|
|
|
7
10
|
import { spawnSync } from "node:child_process"
|
|
8
11
|
import { readFileSync, existsSync, appendFileSync } from "node:fs"
|
|
@@ -35,8 +38,11 @@ Options:
|
|
|
35
38
|
binary, .NET, wheel) for its components, their licenses and
|
|
36
39
|
CVEs; repeatable. Needs trivy on PATH; skipped with a note if
|
|
37
40
|
it is missing, never fails the build for it.
|
|
38
|
-
--timeout <s> Max seconds to wait for the measurement
|
|
41
|
+
--timeout <s> Max seconds to wait for the measurement, and separately for a
|
|
42
|
+
measurement of this project that is already running (default 600)
|
|
39
43
|
--exclude <p> Exclude a generated path relative to the checkout; repeatable
|
|
44
|
+
--strict Fail the step (exit 2) when the run could not be measured.
|
|
45
|
+
Off by default (or env SQ_STRICT=true).
|
|
40
46
|
--dry-run Measure locally and check the package; do not upload (no token needed)
|
|
41
47
|
--json Print the raw result as JSON
|
|
42
48
|
-h, --help Show this help
|
|
@@ -45,7 +51,17 @@ Pull requests that add a dependency whose license your company policy forbids
|
|
|
45
51
|
are listed in the log and the job summary; the check fails for it only where
|
|
46
52
|
your organization turned blocking on.
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
Only a measurement fails the build. When ScaleQuality cannot measure this run
|
|
55
|
+
(unreachable, a server error, a timeout, a failed measurement) or the platform
|
|
56
|
+
refuses it (an invalid or expired token, no active plan, continuous turned off
|
|
57
|
+
for the project), the step reports NOT MEASURED in the log, the job summary and
|
|
58
|
+
--json, never says the gate passed, and exits 0 so your build is not failed by
|
|
59
|
+
it. Pass --strict to fail the step instead.
|
|
60
|
+
|
|
61
|
+
Exit codes:
|
|
62
|
+
0 passed, track-only, or NOT MEASURED
|
|
63
|
+
1 measured: below the cutoff, or a license your organization blocks
|
|
64
|
+
2 NOT MEASURED with --strict, or --dry-run could not prepare the package`)
|
|
49
65
|
process.exit(0)
|
|
50
66
|
}
|
|
51
67
|
|
|
@@ -59,15 +75,151 @@ const artifacts = [
|
|
|
59
75
|
...args.flatMap((arg, i) => (arg === "--artifact" && args[i + 1] ? [args[i + 1]] : [])),
|
|
60
76
|
...(process.env.SQ_ARTIFACTS ? process.env.SQ_ARTIFACTS.split(",").map((s) => s.trim()).filter(Boolean) : []),
|
|
61
77
|
]
|
|
62
|
-
const
|
|
78
|
+
const strict = has("--strict") || /^(1|true|yes)$/i.test(process.env.SQ_STRICT || "")
|
|
79
|
+
// Test-only: shrinks every wait (retries, poll ladder, timeouts) so the suite can walk
|
|
80
|
+
// the slow paths in milliseconds. A pipeline never sets it.
|
|
81
|
+
const timeScale = Number(process.env.SQ_CHECK_TIME_SCALE) > 0 ? Number(process.env.SQ_CHECK_TIME_SCALE) : 1
|
|
82
|
+
const scaled = (ms) => Math.max(1, Math.round(ms * timeScale))
|
|
83
|
+
const timeoutSeconds = Math.max(30, Number(flag("--timeout")) || 600)
|
|
84
|
+
const timeoutMs = scaled(timeoutSeconds * 1000)
|
|
63
85
|
|
|
64
86
|
const base = `${host}/api/insights/continuous`
|
|
65
87
|
const log = (m) => { if (!jsonOut) console.log(m) }
|
|
66
|
-
|
|
88
|
+
// Progress that must not land in the --json stdout.
|
|
89
|
+
const note = (m) => (jsonOut ? console.error : console.log)(m)
|
|
67
90
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
68
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A run that ended without a measured verdict. Only a measurement fails the build
|
|
94
|
+
* (exit 1); everything that kept it from happening exits 0 with a NOT MEASURED
|
|
95
|
+
* warning, or 2 with --strict, whoever caused it. The origin says who, so the log
|
|
96
|
+
* points at the right fix:
|
|
97
|
+
* - scalequality: our side. Unreachable, a 5xx, a timeout, a failed measurement,
|
|
98
|
+
* no score to hold the cutoff against, a protocol the server did not confirm, a
|
|
99
|
+
* measurement of the same project that kept running.
|
|
100
|
+
* - configuration: what the platform holds for this pipeline (the token, the plan,
|
|
101
|
+
* continuous turned on, the project id), or a secret the CI did not hand to the job.
|
|
102
|
+
* - checkout: this job's own checkout could not be packaged (Git history, the
|
|
103
|
+
* archive limit, a file that changed while it was read).
|
|
104
|
+
* A token that expired after its 90 days, a plan that lapsed or a measurement
|
|
105
|
+
* turned off in the platform must not turn every build of the customer red, and a
|
|
106
|
+
* pull request from a fork or from Dependabot gets no secrets at all.
|
|
107
|
+
*/
|
|
108
|
+
class NotMeasured extends Error {
|
|
109
|
+
constructor(origin, reason, message, details = {}) {
|
|
110
|
+
super(message)
|
|
111
|
+
this.origin = origin
|
|
112
|
+
this.reason = reason
|
|
113
|
+
this.details = details
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const notMeasured = (origin, reason, message, details) => { throw new NotMeasured(origin, reason, message, details) }
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* GitHub Actions shows it on the run and on the pull request; Azure Pipelines on the
|
|
120
|
+
* run summary. Both read these commands from stdout, which --json keeps for the result.
|
|
121
|
+
*/
|
|
122
|
+
function annotate(level, title, text) {
|
|
123
|
+
const write = jsonOut ? console.error : console.log
|
|
124
|
+
const data = (s) => String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A")
|
|
125
|
+
const property = (s) => data(s).replace(/:/g, "%3A").replace(/,/g, "%2C")
|
|
126
|
+
if (process.env.GITHUB_ACTIONS === "true") write(`::${level} title=${property(title)}::${data(text)}`)
|
|
127
|
+
else if (process.env.TF_BUILD) write(`##vso[task.logissue type=${level}]${String(text).replace(/[\r\n]+/g, " ")}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function finishNotMeasured({ origin, reason, message, details = {} }) {
|
|
131
|
+
if (dryRun) {
|
|
132
|
+
console.error(`ScaleQuality: ${message}`)
|
|
133
|
+
process.exit(2)
|
|
134
|
+
}
|
|
135
|
+
const exitCode = strict ? 2 : 0
|
|
136
|
+
const outcome = strict
|
|
137
|
+
? "The gate was not evaluated for this commit. Failing the step because --strict is set."
|
|
138
|
+
: "The gate was not evaluated for this commit, so this step exits 0: a run ScaleQuality could not measure does not fail your build. Pass --strict to fail it instead."
|
|
139
|
+
console.error(`ScaleQuality: NOT MEASURED. ${message}`)
|
|
140
|
+
console.error(`ScaleQuality: ${outcome}`)
|
|
141
|
+
annotate(strict ? "error" : "warning", "ScaleQuality: not measured", `${message} ${outcome}`)
|
|
142
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
143
|
+
const md = ["### ScaleQuality: not measured", "", message, "", outcome, ""].join("\n")
|
|
144
|
+
try { appendFileSync(process.env.GITHUB_STEP_SUMMARY, md) } catch { /* the log already says it */ }
|
|
145
|
+
}
|
|
146
|
+
if (jsonOut) console.log(JSON.stringify({ measured: false, outcome: "not_measured", origin, reason, message, strict, exitCode, ...details }))
|
|
147
|
+
process.exit(exitCode)
|
|
148
|
+
}
|
|
149
|
+
|
|
69
150
|
if (!dryRun && (!token || !project)) {
|
|
70
|
-
|
|
151
|
+
finishNotMeasured(new NotMeasured("configuration", "missing_credentials",
|
|
152
|
+
"--token and --project are required (or env SQ_TOKEN / SQ_PROJECT). On a pull request from a fork or from Dependabot the CI does not hand repository secrets to the job. See --help."))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The `error` code ScaleQuality puts in a JSON error body, when there is one. */
|
|
156
|
+
function errorCode(text) {
|
|
157
|
+
try { return JSON.parse(text)?.error } catch { return typeof text === "string" && text.includes("measurement_already_running") ? "measurement_already_running" : undefined }
|
|
158
|
+
}
|
|
159
|
+
const detail = (text) => {
|
|
160
|
+
const s = String(text || "").replace(/\s+/g, " ").trim().slice(0, 200)
|
|
161
|
+
return s ? `: ${s}` : ""
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The token refusals that are the pipeline's to fix, as opposed to our auth store failing (auth_failed). */
|
|
165
|
+
function tokenRefused(code) {
|
|
166
|
+
return code === "token_expired"
|
|
167
|
+
? ["token_expired", "The SQ token expired. Issue a new one in ScaleQuality, update the SQ_TOKEN secret, then revoke the old token."]
|
|
168
|
+
: ["invalid_token", "The SQ token was refused (invalid or revoked). Issue a new one in ScaleQuality and update the SQ_TOKEN secret."]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One request with its own deadline: a host that accepts the connection and then
|
|
173
|
+
* never answers must not hold the job until the CI kills it (a red build again).
|
|
174
|
+
*/
|
|
175
|
+
async function request(url, init, limitMs) {
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(Math.max(1, limitMs)) })
|
|
178
|
+
return { res, text: res.ok ? undefined : await res.text().catch(() => "") }
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { error }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What an upload that was not accepted means, and whether waiting can change it. */
|
|
185
|
+
function uploadProblem(status, text) {
|
|
186
|
+
const code = errorCode(text)
|
|
187
|
+
if (status === 401 && code === "auth_failed") return { origin: "scalequality", reason: "auth_unavailable", message: "ScaleQuality could not check the token (401 auth_failed).", retry: "transient" }
|
|
188
|
+
if (status === 401) { const [reason, message] = tokenRefused(code); return { origin: "configuration", reason, message } }
|
|
189
|
+
if (status === 402) return { origin: "configuration", reason: "subscription_required", message: "Continuous measurement needs an active subscription for this organization." }
|
|
190
|
+
if (status === 404 && code === "project_not_found") return { origin: "configuration", reason: "project_not_found", message: `Project ${project} was not found in this token's organization. Compare --project with the id in the URL of the repository's diagnosis.` }
|
|
191
|
+
if (status === 409 && code === "measurement_already_running") return { origin: "scalequality", reason: "measurement_already_running", message: `Another measurement of this project was still running after ${timeoutSeconds}s of waiting for it.`, retry: "busy" }
|
|
192
|
+
if (status === 409) return { origin: "configuration", reason: "continuous_not_enabled", message: "Continuous measurement is turned off for this project in ScaleQuality. Turn it on there, or remove this step." }
|
|
193
|
+
if (status === 429 || status >= 500) return { origin: "scalequality", reason: "server_error", message: `ScaleQuality answered ${status}${detail(text)}.`, retry: "transient" }
|
|
194
|
+
return { origin: "scalequality", reason: "server_rejected", message: `ScaleQuality did not accept the report (${status}${detail(text)}).` }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A deploy rolling over or a dropped connection lasts seconds; three short retries
|
|
198
|
+
// cover it without hiding a real outage behind minutes of waiting.
|
|
199
|
+
const RETRY_MS = [2000, 5000, 10000]
|
|
200
|
+
// A measurement of the same project already running (another push, a scheduled
|
|
201
|
+
// re-measurement) usually ends in seconds: wait for it instead of skipping this one.
|
|
202
|
+
const BUSY_MS = 10000
|
|
203
|
+
|
|
204
|
+
async function upload(form, auth) {
|
|
205
|
+
const busyUntil = Date.now() + timeoutMs
|
|
206
|
+
let retries = 0
|
|
207
|
+
for (;;) {
|
|
208
|
+
const { res, text, error } = await request(`${base}/report/upload`, { method: "POST", headers: auth, body: form }, timeoutMs)
|
|
209
|
+
if (res?.ok) return res
|
|
210
|
+
const problem = error
|
|
211
|
+
? { origin: "scalequality", reason: "unreachable", message: `Could not reach ${host} (${error.message}).`, retry: "transient" }
|
|
212
|
+
: uploadProblem(res.status, text)
|
|
213
|
+
const wait = problem.retry === "busy" ? scaled(BUSY_MS)
|
|
214
|
+
: problem.retry === "transient" && retries < RETRY_MS.length ? scaled(RETRY_MS[retries++]) : null
|
|
215
|
+
if (wait == null || (problem.retry === "busy" && Date.now() + wait > busyUntil)) {
|
|
216
|
+
notMeasured(problem.origin, problem.reason, problem.message, res ? { httpStatus: res.status } : {})
|
|
217
|
+
}
|
|
218
|
+
note(problem.retry === "busy"
|
|
219
|
+
? "ScaleQuality: another measurement of this project is running; waiting for it to finish before sending this one."
|
|
220
|
+
: `ScaleQuality: ${problem.message} Trying again in ${Math.round(wait / 1000)}s.`)
|
|
221
|
+
await sleep(wait)
|
|
222
|
+
}
|
|
71
223
|
}
|
|
72
224
|
|
|
73
225
|
/**
|
|
@@ -216,21 +368,41 @@ function reportLicenses(license) {
|
|
|
216
368
|
|
|
217
369
|
async function main() {
|
|
218
370
|
const auth = { Authorization: `Bearer ${token}` }
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
371
|
+
let excludes
|
|
372
|
+
try {
|
|
373
|
+
excludes = normalizeExcludes(args.flatMap((arg, i) => arg === "--exclude" ? [args[i + 1]] : []))
|
|
374
|
+
} catch (error) {
|
|
375
|
+
notMeasured("configuration", "invalid_option", error?.message || String(error))
|
|
376
|
+
}
|
|
377
|
+
// The collector logs why it stopped; the warning and the job summary carry it too,
|
|
378
|
+
// since an annotation is read without the log line above it.
|
|
379
|
+
let durabilityProblem
|
|
380
|
+
const durabilityLog = (message) => {
|
|
381
|
+
const stopped = /^ScaleQuality: Git durability not measured \u2014 (.+?)\. No Git history was uploaded\.$/.exec(message)
|
|
382
|
+
if (stopped) durabilityProblem = stopped[1]
|
|
383
|
+
;(jsonOut ? console.error : log)(message)
|
|
384
|
+
}
|
|
385
|
+
const durabilityReport = await measureDurability({ root: process.cwd(), log: durabilityLog })
|
|
386
|
+
if (!durabilityReport) {
|
|
387
|
+
const why = durabilityProblem ? ` (${durabilityProblem})` : ""
|
|
388
|
+
const shallow = /shallow/i.test(durabilityProblem || "") ? " Check the repository out with full history: fetch-depth 0, GIT_DEPTH 0, clone depth full or fetchDepth 0, depending on the CI." : ""
|
|
389
|
+
notMeasured("checkout", "durability_not_collected", `Git durability collection failed${why}. No upload was sent and no new score was submitted.${shallow || " Resolve the Git error before rerunning this step."}`)
|
|
390
|
+
}
|
|
391
|
+
let coverage, packed, buf
|
|
224
392
|
try {
|
|
393
|
+
coverage = await stageCoverageReports({ log })
|
|
225
394
|
packed = prepareCheckoutArchive({ root: process.cwd(), coverageRoot: coverage.root, durabilityReport, excludes, log })
|
|
226
395
|
if (durabilityReport) {
|
|
227
396
|
const head = spawnSync("git", ["--no-optional-locks", "-C", process.cwd(), "rev-parse", "--verify", "HEAD"], { encoding: "utf8", timeout: 10000 })
|
|
228
|
-
if (head.error || head.status !== 0 || head.stdout.trim() !== durabilityReport.headSha)
|
|
397
|
+
if (head.error || head.status !== 0 || head.stdout.trim() !== durabilityReport.headSha) notMeasured("checkout", "head_changed", "Git HEAD changed during measurement or packaging. No upload was sent; rerun after the checkout is stable.")
|
|
229
398
|
}
|
|
230
399
|
if (!dryRun) buf = readFileSync(packed.archivePath)
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (error instanceof NotMeasured) throw error
|
|
402
|
+
notMeasured("checkout", "checkout_not_packaged", error?.message || String(error))
|
|
231
403
|
} finally {
|
|
232
404
|
packed?.cleanup()
|
|
233
|
-
coverage
|
|
405
|
+
coverage?.cleanup()
|
|
234
406
|
}
|
|
235
407
|
const localCheckout = { version: 1, history: packed.history, archiveBytes: packed.bytes, durability: durabilityReport ? { source: "ci", windowDays: 90, settleDays: 14, headSha: durabilityReport.headSha, measuredAt: durabilityReport.measuredAt, engineCommit: durabilityReport.engineCommit } : undefined }
|
|
236
408
|
const limitation = "ScaleQuality: historical secret scanning and commit-convention signals are not included in this checkout report."
|
|
@@ -259,20 +431,11 @@ async function main() {
|
|
|
259
431
|
// Which files the change touched: the other modules of a monorepo it reached are measured again too.
|
|
260
432
|
const touched = changedFiles()
|
|
261
433
|
if (touched) form.append("ciChangedFiles", touched.join("\n"))
|
|
262
|
-
const r = await
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (!r.ok) {
|
|
268
|
-
const body = await r.text().catch(() => "")
|
|
269
|
-
if (r.status === 401) fail("invalid token.")
|
|
270
|
-
if (r.status === 402) fail("continuous measurement needs an active subscription.")
|
|
271
|
-
if (r.status === 409) fail(body.includes("measurement_already_running") ? "another measurement is already running for this project. Wait for it to finish and rerun this step." : "continuous is not enabled for this project. Turn it on in ScaleQuality.")
|
|
272
|
-
fail(`report failed (${r.status}) ${body}`)
|
|
273
|
-
}
|
|
274
|
-
const { runId, modules } = await r.json()
|
|
275
|
-
if (!runId) fail("no run id returned.")
|
|
434
|
+
const r = await upload(form, auth)
|
|
435
|
+
const accepted = await r.json().catch(() => null)
|
|
436
|
+
const runId = accepted?.runId
|
|
437
|
+
const modules = accepted?.modules
|
|
438
|
+
if (!runId) notMeasured("scalequality", "no_run_id", "ScaleQuality accepted the report but returned no measurement id.")
|
|
276
439
|
log(`ScaleQuality: measuring ${project} …`)
|
|
277
440
|
if (Array.isArray(modules) && modules.length) log(`ScaleQuality: also measuring the ${modules.length === 1 ? "module" : `${modules.length} modules`} this change touched: ${modules.map((m) => m.path || "whole repository").join(", ")}`)
|
|
278
441
|
|
|
@@ -290,39 +453,68 @@ async function main() {
|
|
|
290
453
|
const POLL_MS = [1200, 1200, 900, 700, 700, 700, 700, 700, 1000, 1500, 2000, 3000]
|
|
291
454
|
const deadline = Date.now() + timeoutMs
|
|
292
455
|
let last
|
|
456
|
+
// A status read that fails is retried on the next tick until the deadline: one
|
|
457
|
+
// dropped read must not cost a measurement that is about to finish.
|
|
458
|
+
let lastProblem
|
|
293
459
|
let poll = 0
|
|
294
460
|
while (Date.now() < deadline) {
|
|
295
|
-
await sleep(POLL_MS[Math.min(poll++, POLL_MS.length - 1)])
|
|
296
|
-
const
|
|
297
|
-
if (
|
|
298
|
-
|
|
461
|
+
await sleep(scaled(POLL_MS[Math.min(poll++, POLL_MS.length - 1)]))
|
|
462
|
+
const { res, text, error } = await request(`${base}/report/${runId}`, { headers: auth }, scaled(30000))
|
|
463
|
+
if (error) { lastProblem = `could not reach ${host} (${error.message})`; continue }
|
|
464
|
+
if (!res.ok) {
|
|
465
|
+
const code = errorCode(text)
|
|
466
|
+
if (res.status === 401 && code !== "auth_failed") { const [reason, message] = tokenRefused(code); notMeasured("configuration", reason, message, { runId, httpStatus: 401 }) }
|
|
467
|
+
if (res.status === 404) notMeasured("scalequality", "run_not_found", `ScaleQuality no longer finds measurement ${runId}.`, { runId, httpStatus: 404 })
|
|
468
|
+
lastProblem = `ScaleQuality answered ${res.status}${detail(text)}`
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
const read = await res.json().catch(() => null)
|
|
472
|
+
if (!read || typeof read !== "object") { lastProblem = "ScaleQuality answered with an unreadable status"; continue }
|
|
473
|
+
last = read
|
|
474
|
+
lastProblem = undefined
|
|
299
475
|
if (last.status === "COMPLETED" || last.status === "FAILED") break
|
|
300
476
|
if (!jsonOut) process.stdout.write(".")
|
|
301
477
|
}
|
|
302
478
|
if (!jsonOut) process.stdout.write("\n")
|
|
303
479
|
|
|
304
|
-
if (!last || (last.status !== "COMPLETED" && last.status !== "FAILED"))
|
|
305
|
-
|
|
306
|
-
|
|
480
|
+
if (!last || (last.status !== "COMPLETED" && last.status !== "FAILED")) {
|
|
481
|
+
if (lastProblem) notMeasured("scalequality", "status_unavailable", `Could not read the measurement status before the ${timeoutSeconds}s timeout (last attempt: ${lastProblem}).`, { runId })
|
|
482
|
+
notMeasured("scalequality", "timeout", `Timed out after ${timeoutSeconds}s waiting for measurement ${runId}. Raise --timeout if this repository needs longer.`, { runId, status: last?.status ?? null })
|
|
483
|
+
}
|
|
484
|
+
if (last.status === "FAILED") notMeasured("scalequality", "measurement_failed", `The measurement failed on ScaleQuality's side (${last.failureReason || "unknown"}).`, { runId, failureReason: last.failureReason ?? null })
|
|
485
|
+
if (last.checkout?.version !== 1) notMeasured("scalequality", "protocol_unconfirmed", "The server did not confirm the checkout report protocol. No passing gate is claimed; the server needs the compatible continuous-measurement release.", { runId })
|
|
307
486
|
if (durabilityReport && (last.checkout?.durability?.source !== "ci" || last.checkout.durability.windowDays !== 90 || last.checkout.durability.settleDays !== 14 || last.checkout.durability.headSha !== durabilityReport.headSha || last.checkout.durability.measuredAt !== durabilityReport.measuredAt || last.checkout.durability.engineCommit !== durabilityReport.engineCommit)) {
|
|
308
|
-
|
|
487
|
+
notMeasured("scalequality", "durability_unconfirmed", "The server did not confirm this checkout's 90-day durability report. No passing gate is claimed; the server needs the compatible continuous-measurement release.", { runId })
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Exit 1 only for what the measurement said: below the cutoff, or a license the
|
|
491
|
+
// organization blocks. A completed run with no score to hold the cutoff against
|
|
492
|
+
// (gateResult UNKNOWN) or with no verdict at all was not measured, and says so.
|
|
493
|
+
const licenseBlocked = last.license?.result === "BLOCKED"
|
|
494
|
+
const belowCutoff = last.gateResult === "FAILED"
|
|
495
|
+
|| (typeof last.maturity === "number" && typeof last.cutoff === "number" && last.maturity < last.cutoff)
|
|
496
|
+
const gateFailed = last.passed === false && (belowCutoff || licenseBlocked)
|
|
497
|
+
if (typeof last.passed !== "boolean" || (last.passed === false && !gateFailed)) {
|
|
498
|
+
if (!jsonOut) reportLicenses(last.license)
|
|
499
|
+
notMeasured("scalequality", "no_verdict", last.cutoff != null && last.maturity == null
|
|
500
|
+
? `The measurement finished without a maturity score, so the cutoff ${last.cutoff} could not be evaluated.`
|
|
501
|
+
: `The measurement finished without a gate verdict (${last.gateResult ?? "UNKNOWN"}).`, { runId, gateResult: last.gateResult ?? null })
|
|
309
502
|
}
|
|
310
503
|
|
|
311
504
|
if (jsonOut) {
|
|
312
|
-
console.log(JSON.stringify(last))
|
|
505
|
+
console.log(JSON.stringify({ ...last, measured: true, outcome: gateFailed ? "gate_failed" : last.cutoff == null ? "track_only" : "passed" }))
|
|
313
506
|
} else {
|
|
314
507
|
const cut = last.cutoff != null ? ` (cutoff ${last.cutoff})` : " (track only)"
|
|
315
508
|
log(`ScaleQuality: maturity ${last.maturity ?? "—"}${cut}`)
|
|
316
509
|
reportLicenses(last.license)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (last.passed === false && maturityFailed) console.error("ScaleQuality: below the line — failing the build.")
|
|
320
|
-
else if (last.passed === false && licenseBlocked) console.error("ScaleQuality: a forbidden license, failing the build.")
|
|
321
|
-
else if (last.passed === false) console.error("ScaleQuality: the gate did not pass, failing the build.")
|
|
510
|
+
if (gateFailed && belowCutoff) console.error("ScaleQuality: below the line, failing the build.")
|
|
511
|
+
else if (gateFailed) console.error("ScaleQuality: a forbidden license, failing the build.")
|
|
322
512
|
else if (last.cutoff == null) log("ScaleQuality: measurement completed (track only; no cutoff configured).")
|
|
323
513
|
else log("ScaleQuality: passed.")
|
|
324
514
|
}
|
|
325
|
-
process.exit(
|
|
515
|
+
process.exit(gateFailed ? 1 : 0)
|
|
326
516
|
}
|
|
327
517
|
|
|
328
|
-
main().catch((
|
|
518
|
+
main().catch((error) => finishNotMeasured(error instanceof NotMeasured
|
|
519
|
+
? error
|
|
520
|
+
: new NotMeasured("scalequality", "check_error", `The check stopped unexpectedly (${error?.message || String(error)}).`)))
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalequality/check",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "ScaleQuality continuous code-maturity check for CI. Re-measures your repo on every build and fails the pipeline below your maturity line.",
|
|
3
|
+
"version": "0.5.6",
|
|
4
|
+
"description": "ScaleQuality continuous code-maturity check for CI. Re-measures your repo on every build and fails the pipeline below your maturity line, never for a run it could not measure.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"check": "bin/check.mjs",
|