staysfixed 0.3.0 → 0.4.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 +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
package/src/v2/ci.js
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running it where merges happen.
|
|
3
|
+
*
|
|
4
|
+
* A check that only runs on the author's laptop catches what the author was already
|
|
5
|
+
* looking for. The same check running on every pull request catches what nobody was
|
|
6
|
+
* looking for, which is the entire class of thing this tool exists to find. So this file
|
|
7
|
+
* is the part that makes a build server a first-class place to run from.
|
|
8
|
+
*
|
|
9
|
+
* CI IS NOT A WORSE MACHINE THAN A LAPTOP. It is a better one, for this specific job. A
|
|
10
|
+
* fresh runner has the same fonts every time, the same operating system, nothing else
|
|
11
|
+
* competing for a port or for memory, and no half-finished experiment left over from
|
|
12
|
+
* yesterday afternoon. Everything version 2 does rests on "the difference was caused by the
|
|
13
|
+
* change and nothing else", and a machine that is identical on every run is worth a great
|
|
14
|
+
* deal to that claim.
|
|
15
|
+
*
|
|
16
|
+
* THE HARD PART IS THE REFERENCE, and it is worth stating why before reading any code.
|
|
17
|
+
* On a laptop the reference is a build the owner shipped, remembered in a folder that has
|
|
18
|
+
* been sitting there for weeks. A fresh runner has no folder and no memory. So the
|
|
19
|
+
* reference has to be reconstructed out of what a build server does have, which is git —
|
|
20
|
+
* and git turns out to be enough, because `check` accepts a commit and puts that commit
|
|
21
|
+
* back on the machine with `git archive` before walking it. A pull request compared
|
|
22
|
+
* against the commit it forked from is a FULL PAIRED RUN: two builds, one runner, minutes
|
|
23
|
+
* apart. That is the strongest answer this tool can give, and it is available in CI from a
|
|
24
|
+
* bare checkout with no stored record at all.
|
|
25
|
+
*
|
|
26
|
+
* Not every event can reach that, so `referenceForCI` works down a ranked list and says
|
|
27
|
+
* out loud which rung it landed on. The modes are NOT equally strong and this file never
|
|
28
|
+
* pretends they are: every report carries the mode, how it was found, and what would have
|
|
29
|
+
* made it stronger.
|
|
30
|
+
*
|
|
31
|
+
* WHAT THIS FILE MAY NEVER DO: approve anything. It does not cut a reference, it does not
|
|
32
|
+
* write a waiver, it does not record a build as checked in a way that would let one be cut
|
|
33
|
+
* later. CI reports; a person ships. That line is the whole safeguard and a build server
|
|
34
|
+
* sits on the wrong side of it — a green pipeline is not somebody saying "that is what my
|
|
35
|
+
* product does now". So there is deliberately no import of `cutReference` or `setReference`
|
|
36
|
+
* anywhere below, and there never should be.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import fs from 'node:fs';
|
|
40
|
+
import fsp from 'node:fs/promises';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
import { execFile } from 'node:child_process';
|
|
43
|
+
import { promisify } from 'node:util';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
|
|
46
|
+
import { EXIT, messageOf } from '../core/errors.js';
|
|
47
|
+
import { check } from './check.js';
|
|
48
|
+
import { openStore, listBuilds, referencePointer } from './store.js';
|
|
49
|
+
|
|
50
|
+
const exec = promisify(execFile);
|
|
51
|
+
|
|
52
|
+
/** @typedef {import('./types.js').Finding} Finding */
|
|
53
|
+
/** @typedef {import('./check.js').CheckOutcome} CheckOutcome */
|
|
54
|
+
|
|
55
|
+
/** A plain map of environment variables. `process.env` is one; a fake one in a test is another. */
|
|
56
|
+
/** @typedef {Record<string, string|undefined>} Env */
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// What the environment says
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Which build server this is, or none.
|
|
64
|
+
*
|
|
65
|
+
* `none` also covers "something set CI=true and we do not recognise it". That is deliberate:
|
|
66
|
+
* claiming to be on a build server we cannot read the variables of would produce a confident
|
|
67
|
+
* wrong answer about the base commit, which is worse than admitting we do not know.
|
|
68
|
+
*
|
|
69
|
+
* @typedef {'github'|'gitlab'|'circleci'|'none'} CIProvider
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What a pull request is, said the same way whichever server described it.
|
|
74
|
+
*
|
|
75
|
+
* @typedef {object} CIPullRequest
|
|
76
|
+
* @property {string|null} number The number a person would quote.
|
|
77
|
+
* @property {string|null} base The branch it is aimed at, by name: 'main'.
|
|
78
|
+
* @property {string|null} baseSha The base commit, when the server told us one outright.
|
|
79
|
+
* @property {string|null} headSha The tip of the branch being proposed.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What this machine says about itself.
|
|
84
|
+
*
|
|
85
|
+
* @typedef {object} CIEnvironment
|
|
86
|
+
* @property {boolean} on Are we on a build server at all.
|
|
87
|
+
* @property {CIProvider} provider
|
|
88
|
+
* @property {string} name What a person calls it: 'GitHub Actions'.
|
|
89
|
+
* @property {string|null} commit The commit being built. See the note on GitHub below.
|
|
90
|
+
* @property {string|null} branch
|
|
91
|
+
* @property {string|null} repo 'owner/name', when the server says.
|
|
92
|
+
* @property {CIPullRequest|null} pullRequest
|
|
93
|
+
* @property {string|null} beforeSha The commit that was on the branch before this push.
|
|
94
|
+
* @property {string|null} runId
|
|
95
|
+
* @property {string|null} runUrl A link a person can open.
|
|
96
|
+
* @property {string|null} summaryFile A file the job summary is appended to, if there is one.
|
|
97
|
+
* @property {string} note One plain sentence describing all of the above.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read what the environment says about the commit, the branch, the pull request and the base.
|
|
102
|
+
*
|
|
103
|
+
* ONE GOTCHA WORTH THE PARAGRAPH. On GitHub, a `pull_request` event does not build the tip of
|
|
104
|
+
* your branch: it builds a temporary merge of your branch into the base, and `GITHUB_SHA`
|
|
105
|
+
* names that merge commit, which exists nowhere in anybody's history. The tip of the branch
|
|
106
|
+
* is in the event file under `pull_request.head.sha`. Getting this wrong makes every report
|
|
107
|
+
* name a commit nobody can find, so both are read and both are kept.
|
|
108
|
+
*
|
|
109
|
+
* @param {Env} [env]
|
|
110
|
+
* @returns {CIEnvironment}
|
|
111
|
+
*/
|
|
112
|
+
export function detectCI(env = process.env) {
|
|
113
|
+
if (env.GITHUB_ACTIONS === 'true') return fromGitHub(env);
|
|
114
|
+
if (env.GITLAB_CI === 'true') return fromGitLab(env);
|
|
115
|
+
if (env.CIRCLECI === 'true') return fromCircle(env);
|
|
116
|
+
|
|
117
|
+
const something = env.CI === 'true' || env.CI === '1';
|
|
118
|
+
return {
|
|
119
|
+
on: something,
|
|
120
|
+
provider: 'none',
|
|
121
|
+
name: something ? 'a build server we do not recognise' : 'this machine',
|
|
122
|
+
commit: null,
|
|
123
|
+
branch: null,
|
|
124
|
+
repo: null,
|
|
125
|
+
pullRequest: null,
|
|
126
|
+
beforeSha: null,
|
|
127
|
+
runId: null,
|
|
128
|
+
runUrl: null,
|
|
129
|
+
summaryFile: null,
|
|
130
|
+
note: something
|
|
131
|
+
? 'Something says this is a build server, but not one whose variables we know how to read. Nothing here can work out what to compare against on its own, so the commit has to be named by hand.'
|
|
132
|
+
: 'This is not a build server, so everything below falls back to what git can work out locally.',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {Env} env
|
|
138
|
+
* @returns {CIEnvironment}
|
|
139
|
+
*/
|
|
140
|
+
function fromGitHub(env) {
|
|
141
|
+
const event = readEventFile(env.GITHUB_EVENT_PATH);
|
|
142
|
+
const pr = /** @type {Record<string, any>|null} */ (event?.pull_request ?? null);
|
|
143
|
+
// Three ways to know, and all three are used, because relying on the event file alone
|
|
144
|
+
// would silently stop recognising a pull request the moment that file is unreadable —
|
|
145
|
+
// and the whole strength of a CI run rests on knowing which base to fork from.
|
|
146
|
+
const isPr = pr !== null || (env.GITHUB_EVENT_NAME ?? '').startsWith('pull_request') || Boolean(env.GITHUB_BASE_REF);
|
|
147
|
+
|
|
148
|
+
/** @type {CIPullRequest|null} */
|
|
149
|
+
const pullRequest = isPr
|
|
150
|
+
? {
|
|
151
|
+
number: text(pr?.number) ?? text(event?.number),
|
|
152
|
+
base: env.GITHUB_BASE_REF || text(pr?.base?.ref),
|
|
153
|
+
baseSha: text(pr?.base?.sha),
|
|
154
|
+
headSha: text(pr?.head?.sha),
|
|
155
|
+
}
|
|
156
|
+
: null;
|
|
157
|
+
|
|
158
|
+
const repo = env.GITHUB_REPOSITORY ?? null;
|
|
159
|
+
const server = env.GITHUB_SERVER_URL ?? 'https://github.com';
|
|
160
|
+
const runId = env.GITHUB_RUN_ID ?? null;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
on: true,
|
|
164
|
+
provider: 'github',
|
|
165
|
+
name: 'GitHub Actions',
|
|
166
|
+
// The head of the branch, not the throwaway merge commit, whenever we can tell them apart.
|
|
167
|
+
commit: pullRequest?.headSha ?? env.GITHUB_SHA ?? null,
|
|
168
|
+
branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || null,
|
|
169
|
+
repo,
|
|
170
|
+
pullRequest,
|
|
171
|
+
beforeSha: usableSha(text(event?.before)),
|
|
172
|
+
runId,
|
|
173
|
+
runUrl: repo && runId ? `${server}/${repo}/actions/runs/${runId}` : null,
|
|
174
|
+
summaryFile: env.GITHUB_STEP_SUMMARY ?? null,
|
|
175
|
+
note: isPr
|
|
176
|
+
? `GitHub Actions, on pull request ${pullRequest?.number ?? '?'} into ${pullRequest?.base ?? 'the base branch'}.`
|
|
177
|
+
: `GitHub Actions, on a push to ${env.GITHUB_REF_NAME ?? 'a branch'}.`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {Env} env
|
|
183
|
+
* @returns {CIEnvironment}
|
|
184
|
+
*/
|
|
185
|
+
function fromGitLab(env) {
|
|
186
|
+
const isMr = Boolean(env.CI_MERGE_REQUEST_IID);
|
|
187
|
+
return {
|
|
188
|
+
on: true,
|
|
189
|
+
provider: 'gitlab',
|
|
190
|
+
name: 'GitLab CI',
|
|
191
|
+
commit: env.CI_COMMIT_SHA ?? null,
|
|
192
|
+
branch: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || env.CI_COMMIT_REF_NAME || null,
|
|
193
|
+
repo: env.CI_PROJECT_PATH ?? null,
|
|
194
|
+
pullRequest: isMr
|
|
195
|
+
? {
|
|
196
|
+
number: env.CI_MERGE_REQUEST_IID ?? null,
|
|
197
|
+
base: env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME ?? null,
|
|
198
|
+
// GitLab is the one server that hands over the fork point outright, already worked
|
|
199
|
+
// out, with no fetching. Nothing else in this file gets an answer this cheaply.
|
|
200
|
+
baseSha: usableSha(env.CI_MERGE_REQUEST_DIFF_BASE_SHA ?? null),
|
|
201
|
+
headSha: env.CI_COMMIT_SHA ?? null,
|
|
202
|
+
}
|
|
203
|
+
: null,
|
|
204
|
+
beforeSha: usableSha(env.CI_COMMIT_BEFORE_SHA ?? null),
|
|
205
|
+
runId: env.CI_PIPELINE_ID ?? null,
|
|
206
|
+
runUrl: env.CI_JOB_URL ?? env.CI_PIPELINE_URL ?? null,
|
|
207
|
+
// GitLab has no job summary of its own. The report still gets written to a file and
|
|
208
|
+
// uploaded, it just does not appear on the pipeline page.
|
|
209
|
+
summaryFile: null,
|
|
210
|
+
note: isMr
|
|
211
|
+
? `GitLab CI, on merge request ${env.CI_MERGE_REQUEST_IID} into ${env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME ?? 'the target branch'}.`
|
|
212
|
+
: `GitLab CI, on a push to ${env.CI_COMMIT_REF_NAME ?? 'a branch'}.`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {Env} env
|
|
218
|
+
* @returns {CIEnvironment}
|
|
219
|
+
*/
|
|
220
|
+
function fromCircle(env) {
|
|
221
|
+
const number = env.CIRCLE_PR_NUMBER ?? lastSegment(env.CIRCLE_PULL_REQUEST);
|
|
222
|
+
const repo = env.CIRCLE_PROJECT_USERNAME && env.CIRCLE_PROJECT_REPONAME ? `${env.CIRCLE_PROJECT_USERNAME}/${env.CIRCLE_PROJECT_REPONAME}` : null;
|
|
223
|
+
return {
|
|
224
|
+
on: true,
|
|
225
|
+
provider: 'circleci',
|
|
226
|
+
name: 'CircleCI',
|
|
227
|
+
commit: env.CIRCLE_SHA1 ?? null,
|
|
228
|
+
branch: env.CIRCLE_BRANCH ?? null,
|
|
229
|
+
repo,
|
|
230
|
+
// CircleCI tells a job the pull request exists and refuses to say what it is aimed at.
|
|
231
|
+
// Recording the number and admitting the base is unknown is the honest shape: the base
|
|
232
|
+
// then has to come from git, and if git cannot supply it the report says so.
|
|
233
|
+
pullRequest: number ? { number, base: null, baseSha: null, headSha: env.CIRCLE_SHA1 ?? null } : null,
|
|
234
|
+
beforeSha: null,
|
|
235
|
+
runId: env.CIRCLE_BUILD_NUM ?? null,
|
|
236
|
+
runUrl: env.CIRCLE_BUILD_URL ?? null,
|
|
237
|
+
summaryFile: null,
|
|
238
|
+
note: number
|
|
239
|
+
? `CircleCI, on pull request ${number}. CircleCI does not tell a job which branch the request is aimed at, so the base has to be worked out from git.`
|
|
240
|
+
: `CircleCI, on a push to ${env.CIRCLE_BRANCH ?? 'a branch'}.`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// The reference
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* How a build server can get hold of something to compare against, best first.
|
|
250
|
+
*
|
|
251
|
+
* - `named` Somebody said which commit. Nothing beats being told.
|
|
252
|
+
* - `merge-base` The commit this branch forked from. The right answer for a pull
|
|
253
|
+
* request: it isolates what THIS branch did from everything else that
|
|
254
|
+
* landed on the base while it was open.
|
|
255
|
+
* - `released` The commit the project's own reference points at — what its owner
|
|
256
|
+
* last said ship to. The right answer for a push to a main branch.
|
|
257
|
+
* - `last-tag` The most recent tag in this history. Fair, not strong: other people's
|
|
258
|
+
* merges since that tag show up as differences too.
|
|
259
|
+
* - `previous-commit` What the branch was before this push. Narrow: it proves this push,
|
|
260
|
+
* not this branch.
|
|
261
|
+
* - `stored-record` Observations committed into the repository or restored from a cache.
|
|
262
|
+
* No old build is booted at all. Weakest, and it carries a caveat about
|
|
263
|
+
* the machine that took them.
|
|
264
|
+
* - `none` Nothing to compare against. Not a pass, and it must not exit zero.
|
|
265
|
+
*
|
|
266
|
+
* @typedef {'named'|'merge-base'|'released'|'last-tag'|'previous-commit'|'stored-record'|'none'} CIReferenceMode
|
|
267
|
+
*/
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* How much an answer from this mode is worth. Four words rather than a number, because a
|
|
271
|
+
* number invites somebody to set a threshold on it and there are no thresholds in version 2.
|
|
272
|
+
* @typedef {'strong'|'fair'|'weak'|'none'} CIStrength
|
|
273
|
+
*/
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* One mode that was thought about, and what happened.
|
|
277
|
+
* @typedef {object} CIConsidered
|
|
278
|
+
* @property {CIReferenceMode} mode
|
|
279
|
+
* @property {boolean} available
|
|
280
|
+
* @property {string} why Why it was used, or why it could not be.
|
|
281
|
+
* @property {string} [unlockedBy] The concrete thing that would make it available.
|
|
282
|
+
*/
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* What a build server found to compare against, and how much it is worth.
|
|
286
|
+
*
|
|
287
|
+
* @typedef {object} CIReference
|
|
288
|
+
* @property {CIReferenceMode} mode
|
|
289
|
+
* @property {string|null} against Hand this straight to `check({against})`. A commit.
|
|
290
|
+
* @property {boolean} paired Can the old build be booted and walked here.
|
|
291
|
+
* @property {CIStrength} strength
|
|
292
|
+
* @property {string} how Plain English: how this reference was found.
|
|
293
|
+
* @property {string} why Plain English: why this one and not a stronger one.
|
|
294
|
+
* @property {string} [caveat] The warning that belongs on every report of this run.
|
|
295
|
+
* @property {string} [unlockedBy] The concrete thing that would make it stronger.
|
|
296
|
+
* @property {CIConsidered[]} considered
|
|
297
|
+
* @property {boolean} shallow The checkout has no full history, which rules a lot out.
|
|
298
|
+
* @property {'same'|'different'|'unknown'} [machine]
|
|
299
|
+
* Only for a stored record: was it taken on a machine like
|
|
300
|
+
* this one. A record from a different machine reintroduces
|
|
301
|
+
* every difference that comes from the machine being
|
|
302
|
+
* different, which is the thing pairing exists to remove.
|
|
303
|
+
*/
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Work out what this build server can compare against.
|
|
307
|
+
*
|
|
308
|
+
* Runs no network calls and writes nothing. A shallow checkout is detected and REPORTED
|
|
309
|
+
* rather than quietly deepened: fetching more history is a thing a workflow file should say
|
|
310
|
+
* it is doing, in the open, not something a library does behind a job's back.
|
|
311
|
+
*
|
|
312
|
+
* @param {{cwd?: string, env?: Env, against?: string, product?: string}} [opts]
|
|
313
|
+
* @returns {Promise<CIReference>}
|
|
314
|
+
*/
|
|
315
|
+
export async function referenceForCI(opts = {}) {
|
|
316
|
+
const cwd = path.resolve(opts.cwd ?? process.cwd());
|
|
317
|
+
const env = opts.env ?? process.env;
|
|
318
|
+
const ci = detectCI(env);
|
|
319
|
+
const shallow = (await git(cwd, ['rev-parse', '--is-shallow-repository'])) === 'true';
|
|
320
|
+
const deepen = 'Check out the full history. On GitHub that is `fetch-depth: 0` on actions/checkout; on GitLab it is `GIT_DEPTH: 0`.';
|
|
321
|
+
const headSha = await resolveCommit(cwd, 'HEAD');
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A reference that turns out to BE the build under test is the most dangerous answer this
|
|
325
|
+
* file could give: everything matches, nothing is reported, and the run looks like the
|
|
326
|
+
* strongest possible pass. It is how a shallow clone kills the whole tool silently — clone
|
|
327
|
+
* at depth one and the fork point of every branch comes back as HEAD.
|
|
328
|
+
*
|
|
329
|
+
* So every candidate goes through here, and one that is the same commit is thrown away.
|
|
330
|
+
*
|
|
331
|
+
* @param {string|null} sha
|
|
332
|
+
* @returns {string|null}
|
|
333
|
+
*/
|
|
334
|
+
const notThisBuild = (sha) => (sha && headSha && sha === headSha ? null : sha);
|
|
335
|
+
const sameBuild = 'is this exact build. Comparing something against itself proves nothing, so it was not used.';
|
|
336
|
+
|
|
337
|
+
/** @type {CIConsidered[]} */
|
|
338
|
+
const considered = [];
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @param {CIReferenceMode} mode
|
|
342
|
+
* @param {string} why
|
|
343
|
+
* @param {string} [unlockedBy]
|
|
344
|
+
*/
|
|
345
|
+
const missed = (mode, why, unlockedBy) => {
|
|
346
|
+
/** @type {CIConsidered} */
|
|
347
|
+
const entry = { mode, available: false, why };
|
|
348
|
+
if (unlockedBy) entry.unlockedBy = unlockedBy;
|
|
349
|
+
considered.push(entry);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// ---- named -------------------------------------------------------------
|
|
353
|
+
if (opts.against) {
|
|
354
|
+
const named = await resolveCommit(cwd, opts.against);
|
|
355
|
+
const sha = notThisBuild(named);
|
|
356
|
+
if (named && !sha) {
|
|
357
|
+
missed('named', `You named ${opts.against}, and that ${sameBuild}`, 'Name an earlier commit, tag or release.');
|
|
358
|
+
} else if (sha) {
|
|
359
|
+
considered.push({ mode: 'named', available: true, why: `Somebody named ${opts.against} outright.` });
|
|
360
|
+
return {
|
|
361
|
+
mode: 'named',
|
|
362
|
+
against: sha,
|
|
363
|
+
paired: true,
|
|
364
|
+
strength: 'strong',
|
|
365
|
+
how: `You named ${opts.against}, and it is in this checkout.`,
|
|
366
|
+
why: 'Nothing beats being told which build counts as working.',
|
|
367
|
+
considered,
|
|
368
|
+
shallow,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
else missed('named', `${opts.against} was named, but there is no such commit in this checkout.`, shallow ? deepen : 'Check the tag or commit still exists in this repository.');
|
|
372
|
+
} else {
|
|
373
|
+
missed('named', 'Nobody named a commit to compare against.');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const wantsMergeBase = ci.pullRequest !== null;
|
|
377
|
+
|
|
378
|
+
// ---- merge base --------------------------------------------------------
|
|
379
|
+
if (wantsMergeBase) {
|
|
380
|
+
const found = await findMergeBase(cwd, ci);
|
|
381
|
+
if (found && !notThisBuild(found.sha)) {
|
|
382
|
+
// A depth-one clone answers every merge-base question with HEAD. This is the exact
|
|
383
|
+
// shape of the silent green run, and it has to be caught here rather than reported
|
|
384
|
+
// as the strongest mode there is.
|
|
385
|
+
missed(
|
|
386
|
+
'merge-base',
|
|
387
|
+
shallow
|
|
388
|
+
? `The fork point came back as this same commit, which ${sameBuild} A clone with no history always answers this way, so nothing has really been worked out.`
|
|
389
|
+
: `This branch has nothing of its own on top of its base yet, so the fork point ${sameBuild}`,
|
|
390
|
+
shallow ? deepen : 'Push a commit to the branch, or aim the check at a different base.',
|
|
391
|
+
);
|
|
392
|
+
} else if (found) {
|
|
393
|
+
considered.push({ mode: 'merge-base', available: true, why: found.how });
|
|
394
|
+
return {
|
|
395
|
+
mode: 'merge-base',
|
|
396
|
+
against: found.sha,
|
|
397
|
+
paired: true,
|
|
398
|
+
strength: 'strong',
|
|
399
|
+
how: found.how,
|
|
400
|
+
why: 'This is the right one for a pull request: it compares what this branch did, and nothing that landed on the base branch while it was open.',
|
|
401
|
+
considered,
|
|
402
|
+
shallow,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
else
|
|
406
|
+
missed(
|
|
407
|
+
'merge-base',
|
|
408
|
+
shallow
|
|
409
|
+
? 'This is a pull request, but the checkout has no history, so the commit this branch forked from cannot be worked out.'
|
|
410
|
+
: 'This is a pull request, but the base branch is not in this checkout, so the fork point cannot be worked out.',
|
|
411
|
+
deepen,
|
|
412
|
+
);
|
|
413
|
+
} else {
|
|
414
|
+
missed('merge-base', ci.on ? 'This run is not a pull request, so there is no branch to find a fork point for.' : 'Not on a build server, so there is no pull request to read.');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---- released ----------------------------------------------------------
|
|
418
|
+
const released = await releasedCommit(cwd, opts.product);
|
|
419
|
+
if (released) {
|
|
420
|
+
const found = await resolveCommit(cwd, released.sha);
|
|
421
|
+
const sha = notThisBuild(found);
|
|
422
|
+
if (found && !sha) {
|
|
423
|
+
missed('released', `The build this project last said ship to ${sameBuild}`, 'Nothing is wrong: you are checking the build you already shipped.');
|
|
424
|
+
} else if (sha) {
|
|
425
|
+
considered.push({ mode: 'released', available: true, why: `The project's reference points at ${short(sha)}, and it is in this checkout.` });
|
|
426
|
+
/** @type {CIReference} */
|
|
427
|
+
const result = {
|
|
428
|
+
mode: 'released',
|
|
429
|
+
against: sha,
|
|
430
|
+
paired: true,
|
|
431
|
+
strength: 'strong',
|
|
432
|
+
how: `The build this project last said ship to: ${short(sha)}${released.note ? ` — ${released.note}` : ''}.`,
|
|
433
|
+
why: 'This is the definition of working for this product, set by a person shipping, not by anything on this build server.',
|
|
434
|
+
considered,
|
|
435
|
+
shallow,
|
|
436
|
+
};
|
|
437
|
+
// On a pull request this is the second choice for a reason worth saying: everything
|
|
438
|
+
// merged since the release counts as a difference too, so the list is longer than the
|
|
439
|
+
// branch is responsible for.
|
|
440
|
+
if (wantsMergeBase) {
|
|
441
|
+
result.strength = 'fair';
|
|
442
|
+
result.caveat =
|
|
443
|
+
'The fork point of this branch could not be worked out, so this was compared against the last shipped build instead. Anything else that was merged since that release will show up here as well, even though this branch did not do it.';
|
|
444
|
+
result.unlockedBy = deepen;
|
|
445
|
+
}
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
else missed('released', `The project's reference points at ${short(released.sha)}, and that commit is not in this checkout.`, shallow ? deepen : 'Make sure the commit that was shipped is still in this repository.');
|
|
449
|
+
} else {
|
|
450
|
+
missed('released', 'This project has no build on record as working. Only its owner can set one, by shipping.');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---- last tag ----------------------------------------------------------
|
|
454
|
+
const tag = await lastTag(cwd);
|
|
455
|
+
if (tag) {
|
|
456
|
+
considered.push({ mode: 'last-tag', available: true, why: `The most recent tag in this history is ${tag.name}.` });
|
|
457
|
+
return {
|
|
458
|
+
mode: 'last-tag',
|
|
459
|
+
against: tag.sha,
|
|
460
|
+
paired: true,
|
|
461
|
+
strength: 'fair',
|
|
462
|
+
how: `The most recent tag in this history: ${tag.name} (${short(tag.sha)}).`,
|
|
463
|
+
why: 'There is no pull request base and nothing on record as working, so the last tag is the nearest thing to a build somebody was happy with.',
|
|
464
|
+
caveat:
|
|
465
|
+
'A tag is not the same as a build somebody said ship to. Everything merged since that tag shows up here as a difference, whoever wrote it.',
|
|
466
|
+
unlockedBy: 'Record a reference when you release, so this compares against what you actually shipped.',
|
|
467
|
+
considered,
|
|
468
|
+
shallow,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
missed('last-tag', shallow ? 'No tags are in this checkout, and a shallow clone fetches none.' : 'This repository has no tags.', shallow ? deepen : 'Tag your releases.');
|
|
472
|
+
|
|
473
|
+
// ---- previous commit ---------------------------------------------------
|
|
474
|
+
const before = notThisBuild(ci.beforeSha ? await resolveCommit(cwd, ci.beforeSha) : await resolveCommit(cwd, 'HEAD^'));
|
|
475
|
+
if (before) {
|
|
476
|
+
considered.push({ mode: 'previous-commit', available: true, why: `The branch was at ${short(before)} before this push.` });
|
|
477
|
+
return {
|
|
478
|
+
mode: 'previous-commit',
|
|
479
|
+
against: before,
|
|
480
|
+
paired: true,
|
|
481
|
+
strength: 'fair',
|
|
482
|
+
how: `What this branch was immediately before: ${short(before)}.`,
|
|
483
|
+
why: 'Nothing stronger was available, and one commit back is still a real build that can be booted and walked here.',
|
|
484
|
+
caveat:
|
|
485
|
+
'This proves what this one push did, not what this branch did. A break introduced three pushes ago is in both builds, so it will not appear here at all.',
|
|
486
|
+
unlockedBy: 'Run this on pull requests as well, where the fork point of the whole branch is available.',
|
|
487
|
+
considered,
|
|
488
|
+
shallow,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
missed('previous-commit', 'There is no earlier commit in this checkout.', shallow ? deepen : undefined);
|
|
492
|
+
|
|
493
|
+
// ---- stored record -----------------------------------------------------
|
|
494
|
+
const stored = await storedRecord(cwd, opts.product);
|
|
495
|
+
if (stored) {
|
|
496
|
+
considered.push({ mode: 'stored-record', available: true, why: `There are stored observations for ${stored.buildId}.` });
|
|
497
|
+
const same = stored.machine === 'same';
|
|
498
|
+
return {
|
|
499
|
+
mode: 'stored-record',
|
|
500
|
+
// Null on purpose. The engine finds a stored record through its own reference pointer;
|
|
501
|
+
// handing it a commit it cannot boot would turn a weak answer into a blocked run.
|
|
502
|
+
against: null,
|
|
503
|
+
paired: false,
|
|
504
|
+
strength: same ? 'fair' : 'weak',
|
|
505
|
+
how: `No old build could be put back on this machine, so this used the observations stored for ${stored.buildId}.`,
|
|
506
|
+
why: 'Nothing in this checkout could be booted and walked, so all that is left is the record the old build left the last time it ran.',
|
|
507
|
+
caveat: same
|
|
508
|
+
? 'No old build was run here. This compares against a record, which cannot catch anything that only shows up when the old build is actually running. The record was taken on a machine like this one, so at least the fonts and the operating system match.'
|
|
509
|
+
: 'No old build was run here, and the stored record was taken on a DIFFERENT machine. Different fonts, a different operating system and different paths all count as differences, so expect noise this run cannot tell apart from a real change.',
|
|
510
|
+
unlockedBy: same ? deepen : 'Take the stored record on a runner like this one — cache the .staysfixed folder from a job on your main branch — or check out the full history so an old build can be booted here instead.',
|
|
511
|
+
considered,
|
|
512
|
+
shallow,
|
|
513
|
+
machine: stored.machine,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
missed('stored-record', 'There are no stored observations in this checkout either.', 'Commit the .staysfixed folder, or restore it from a cache written by a job on your main branch.');
|
|
517
|
+
|
|
518
|
+
// ---- nothing -----------------------------------------------------------
|
|
519
|
+
return {
|
|
520
|
+
mode: 'none',
|
|
521
|
+
against: null,
|
|
522
|
+
paired: false,
|
|
523
|
+
strength: 'none',
|
|
524
|
+
how: 'Nothing was found to compare against.',
|
|
525
|
+
why: 'No named commit, no pull request base, no reference, no tag, no earlier commit and no stored record. There is nothing in this checkout that says what this product used to do.',
|
|
526
|
+
caveat: 'This run proves nothing about your product either way. It is not a pass.',
|
|
527
|
+
unlockedBy: deepen,
|
|
528
|
+
considered,
|
|
529
|
+
shallow,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The commit this branch forked from.
|
|
535
|
+
*
|
|
536
|
+
* Three ways, in order of how right the answer is. `git merge-base` is the true fork point.
|
|
537
|
+
* The base commit the server hands over is the tip of the base branch at the moment the
|
|
538
|
+
* event fired, which is close but not the same thing — if the base has moved since the
|
|
539
|
+
* branch was cut, it includes work this branch never touched.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} cwd
|
|
542
|
+
* @param {CIEnvironment} ci
|
|
543
|
+
* @returns {Promise<{sha: string, how: string}|null>}
|
|
544
|
+
*/
|
|
545
|
+
async function findMergeBase(cwd, ci) {
|
|
546
|
+
const pr = ci.pullRequest;
|
|
547
|
+
// The tip of the branch, and it must be a commit this checkout actually has. A server can
|
|
548
|
+
// name a commit that was never fetched — a shallow clone, or a merge commit built somewhere
|
|
549
|
+
// else — and `git merge-base` against a name git has never heard of fails with no useful
|
|
550
|
+
// message. Falling back to HEAD is right: HEAD is what is about to be walked either way.
|
|
551
|
+
/** @type {string|null} */
|
|
552
|
+
let head = null;
|
|
553
|
+
for (const candidate of [pr?.headSha, ci.commit, 'HEAD']) {
|
|
554
|
+
if (!candidate) continue;
|
|
555
|
+
head = await resolveCommit(cwd, candidate);
|
|
556
|
+
if (head) break;
|
|
557
|
+
}
|
|
558
|
+
if (!head) return null;
|
|
559
|
+
|
|
560
|
+
/** @type {string[]} */
|
|
561
|
+
const bases = [];
|
|
562
|
+
if (pr?.base) bases.push(`origin/${pr.base}`, `refs/remotes/origin/${pr.base}`, pr.base);
|
|
563
|
+
// No base branch was named — CircleCI does this — so try the names a main branch usually has.
|
|
564
|
+
if (bases.length === 0) bases.push('origin/main', 'origin/master', 'origin/HEAD');
|
|
565
|
+
|
|
566
|
+
for (const base of bases) {
|
|
567
|
+
if (!(await resolveCommit(cwd, base))) continue;
|
|
568
|
+
const found = await git(cwd, ['merge-base', base, head]);
|
|
569
|
+
if (found) return { sha: found, how: `The commit this branch forked from ${base}: ${short(found)}.` };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (pr?.baseSha) {
|
|
573
|
+
const sha = await resolveCommit(cwd, pr.baseSha);
|
|
574
|
+
if (sha) {
|
|
575
|
+
const merged = await git(cwd, ['merge-base', sha, head]);
|
|
576
|
+
const use = merged ?? sha;
|
|
577
|
+
return {
|
|
578
|
+
sha: use,
|
|
579
|
+
how: merged
|
|
580
|
+
? `The commit this branch forked from, worked out from the base ${ci.name} named: ${short(use)}.`
|
|
581
|
+
: `The tip of the base branch when this pull request was opened: ${short(use)}. That is close to the fork point, not exactly it.`,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The commit this project's own reference points at — the build somebody shipped.
|
|
590
|
+
*
|
|
591
|
+
* Read only. Nothing in this file may move that pointer.
|
|
592
|
+
*
|
|
593
|
+
* @param {string} cwd
|
|
594
|
+
* @param {string} [product]
|
|
595
|
+
* @returns {Promise<{sha: string, note: string}|null>}
|
|
596
|
+
*/
|
|
597
|
+
async function releasedCommit(cwd, product) {
|
|
598
|
+
try {
|
|
599
|
+
const store = openStore({ root: cwd });
|
|
600
|
+
const name = product ?? (await productName(cwd));
|
|
601
|
+
if (!name) return null;
|
|
602
|
+
const pointer = await referencePointer(store, name);
|
|
603
|
+
if (!pointer) return null;
|
|
604
|
+
const builds = await listBuilds(store, { product: name });
|
|
605
|
+
const hit = builds.find((b) => b.fingerprint.id === pointer.buildId);
|
|
606
|
+
const sha = hit?.fingerprint.gitSha ?? shaFromBuildId(pointer.buildId);
|
|
607
|
+
if (!sha) return null;
|
|
608
|
+
return { sha, note: hit?.fingerprint.version ? `version ${hit.fingerprint.version}` : '' };
|
|
609
|
+
} catch {
|
|
610
|
+
// A store that will not open is a reason to try the next mode, never a reason to fail.
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Is there a stored record here at all, and was it taken on a machine like this one.
|
|
617
|
+
*
|
|
618
|
+
* @param {string} cwd
|
|
619
|
+
* @param {string} [product]
|
|
620
|
+
* @returns {Promise<{buildId: string, machine: 'same'|'different'|'unknown'}|null>}
|
|
621
|
+
*/
|
|
622
|
+
async function storedRecord(cwd, product) {
|
|
623
|
+
try {
|
|
624
|
+
const store = openStore({ root: cwd });
|
|
625
|
+
const name = product ?? (await productName(cwd));
|
|
626
|
+
if (!name) return null;
|
|
627
|
+
const pointer = await referencePointer(store, name);
|
|
628
|
+
const builds = await listBuilds(store, { product: name });
|
|
629
|
+
if (builds.length === 0) return null;
|
|
630
|
+
const hit = (pointer && builds.find((b) => b.fingerprint.id === pointer.buildId)) || builds[0];
|
|
631
|
+
const here = `${process.platform}-${process.arch}`;
|
|
632
|
+
/** @type {'same'|'different'|'unknown'} */
|
|
633
|
+
const machine = !hit.fingerprint.platform ? 'unknown' : hit.fingerprint.platform === here ? 'same' : 'different';
|
|
634
|
+
return { buildId: hit.fingerprint.id, machine };
|
|
635
|
+
} catch {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// The report
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* What a build server prints, writes and exits with.
|
|
646
|
+
*
|
|
647
|
+
* @typedef {object} CIReport
|
|
648
|
+
* @property {boolean} ok
|
|
649
|
+
* @property {boolean} blocked The check did not run. Neither a pass nor a failure.
|
|
650
|
+
* @property {number} exitCode 0 nothing changed, 1 something did, 2 no answer at all.
|
|
651
|
+
* @property {string} headline One sentence, the thing a person reads first.
|
|
652
|
+
* @property {string} markdown For a job summary page.
|
|
653
|
+
* @property {string} text For the job log, where markdown is just noise.
|
|
654
|
+
*/
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Turn a verdict into the table a build server shows, and the code it exits with.
|
|
658
|
+
*
|
|
659
|
+
* THE EXIT CODES ARE THE POINT OF THE WHOLE FILE, so they are the part to get right:
|
|
660
|
+
*
|
|
661
|
+
* 0 Nothing that already worked has changed. Merge away.
|
|
662
|
+
* 1 Something changed that nobody accounted for. A person or an agent has to look.
|
|
663
|
+
* 2 The check could not run, or there was nothing to compare against. This is NOT a
|
|
664
|
+
* pass. A run that proved nothing exiting zero is the exact failure this tool exists
|
|
665
|
+
* to prevent, and it would be an easy and invisible one to ship.
|
|
666
|
+
*
|
|
667
|
+
* @param {CheckOutcome} verdict
|
|
668
|
+
* @param {{reference?: CIReference, env?: CIEnvironment, durationMs?: number, remembered?: boolean}} [extra]
|
|
669
|
+
* @returns {CIReport}
|
|
670
|
+
*/
|
|
671
|
+
export function reportForCI(verdict, extra = {}) {
|
|
672
|
+
const reference = extra.reference;
|
|
673
|
+
const blocked = verdict.blocked === true;
|
|
674
|
+
const nothingToCompare = !verdict.reference || verdict.reference.id === '';
|
|
675
|
+
const sealed = (verdict.findings ?? []).filter((f) => f.sealed);
|
|
676
|
+
const rest = (verdict.findings ?? []).filter((f) => !f.sealed);
|
|
677
|
+
const unstable = verdict.newlyUnstable ?? [];
|
|
678
|
+
|
|
679
|
+
const exitCode = blocked || nothingToCompare ? EXIT.error : verdict.ok ? EXIT.ok : EXIT.failed;
|
|
680
|
+
|
|
681
|
+
const headline = blocked
|
|
682
|
+
? 'The check could not run, so nothing here says anything about your product either way.'
|
|
683
|
+
: nothingToCompare
|
|
684
|
+
? 'There was nothing to compare against, so this run proved nothing. It is not a pass.'
|
|
685
|
+
: verdict.ok
|
|
686
|
+
? 'Nothing that already worked has changed.'
|
|
687
|
+
: sealed.length > 0
|
|
688
|
+
? `${count(sealed.length, 'thing', 'things')} changed that no agent may wave through, and a person has to look.`
|
|
689
|
+
: `${count(verdict.findings.length, 'thing', 'things')} changed that nobody asked for.`;
|
|
690
|
+
|
|
691
|
+
/** @type {string[]} */
|
|
692
|
+
const md = [];
|
|
693
|
+
md.push('## Stays Fixed');
|
|
694
|
+
md.push('');
|
|
695
|
+
md.push(`**${headline}**`);
|
|
696
|
+
md.push('');
|
|
697
|
+
|
|
698
|
+
// The admission that this run is weaker than usual goes above the table, not below it.
|
|
699
|
+
// Nobody scrolls back up past a green tick to find out it was not worth much.
|
|
700
|
+
const warnings = [reference?.caveat, verdict.modeWarning].filter((w) => typeof w === 'string' && w !== '');
|
|
701
|
+
for (const w of warnings) md.push(`> **Read this first.** ${w}`, '>');
|
|
702
|
+
if (warnings.length > 0) md.push('');
|
|
703
|
+
|
|
704
|
+
md.push('| | |');
|
|
705
|
+
md.push('| --- | --- |');
|
|
706
|
+
if (reference) {
|
|
707
|
+
md.push(row('Compared against', reference.how));
|
|
708
|
+
md.push(row('How that was chosen', reference.why));
|
|
709
|
+
md.push(
|
|
710
|
+
row(
|
|
711
|
+
'How much it is worth',
|
|
712
|
+
reference.mode === 'none'
|
|
713
|
+
? 'nothing — there was no old build and no record of one, so this run did not compare anything'
|
|
714
|
+
: `${reference.strength} — ${reference.paired ? 'the old build was put back on this runner and walked again' : 'no old build was run; this is a stored record'}`,
|
|
715
|
+
),
|
|
716
|
+
);
|
|
717
|
+
} else if (!nothingToCompare) {
|
|
718
|
+
md.push(row('Compared against', nameOfBuild(verdict.reference)));
|
|
719
|
+
}
|
|
720
|
+
if (!blocked && !nothingToCompare) {
|
|
721
|
+
const paths = verdict.coverage?.paths ?? 0;
|
|
722
|
+
const journeys = verdict.coverage?.journeys ?? 0;
|
|
723
|
+
md.push(row('Looked at', `${count(paths, 'address', 'addresses')} across ${count(journeys, 'journey', 'journeys')}`));
|
|
724
|
+
md.push(row('Differences', `${count(verdict.differencesReal ?? 0, 'real one', 'real ones')}, and ${count(verdict.differencesNoise ?? 0, 'thing', 'things')} the product disagrees with itself about anyway`));
|
|
725
|
+
if (unstable.length > 0) md.push(row('Newly unpredictable', `${count(unstable.length, 'address', 'addresses')} that used to give the same answer every time`));
|
|
726
|
+
const waived = verdict.accounted?.waived ?? 0;
|
|
727
|
+
if (waived > 0) md.push(row('Already accounted for', `${count(waived, 'finding was', 'findings were')} dropped because an agent had recorded them as intended before the run`));
|
|
728
|
+
}
|
|
729
|
+
const took = extra.durationMs ?? verdict.durationMs;
|
|
730
|
+
if (typeof took === 'number' && took > 0) md.push(row('Took', minutes(took)));
|
|
731
|
+
if (extra.env?.runUrl) md.push(row('This run', extra.env.runUrl));
|
|
732
|
+
md.push('');
|
|
733
|
+
|
|
734
|
+
if (sealed.length > 0) {
|
|
735
|
+
md.push('### A person has to look at these');
|
|
736
|
+
md.push('');
|
|
737
|
+
for (const f of sealed) md.push(...findingLines(f));
|
|
738
|
+
md.push('');
|
|
739
|
+
}
|
|
740
|
+
if (rest.length > 0) {
|
|
741
|
+
md.push(sealed.length > 0 ? '### And these' : '### What changed that nobody asked for');
|
|
742
|
+
md.push('');
|
|
743
|
+
for (const f of rest) md.push(...findingLines(f));
|
|
744
|
+
md.push('');
|
|
745
|
+
}
|
|
746
|
+
if (unstable.length > 0) {
|
|
747
|
+
md.push('### These used to give the same answer every time, and now they do not');
|
|
748
|
+
md.push('');
|
|
749
|
+
for (const u of unstable.slice(0, 10)) md.push(`- \`${u.path}\` — two runs of this same build disagree about it, and the old build did not`);
|
|
750
|
+
if (unstable.length > 10) md.push(`- and ${unstable.length - 10} more.`);
|
|
751
|
+
md.push('');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Said on a clean run too. A gap that is only mentioned when something fails is a gap
|
|
755
|
+
// nobody ever sees, and quiet that cannot be shown to be earned is worth nothing.
|
|
756
|
+
const gaps = verdict.coverage?.gaps ?? [];
|
|
757
|
+
if (gaps.length > 0) {
|
|
758
|
+
md.push('### What it did not look at');
|
|
759
|
+
md.push('');
|
|
760
|
+
for (const g of gaps.slice(0, 12)) md.push(`- ${g.what} ${g.why}${g.unlockedBy ? ` **${g.unlockedBy}**` : ''}`);
|
|
761
|
+
if (gaps.length > 12) md.push(`- and ${gaps.length - 12} more. All of them are in the evidence attached to this run.`);
|
|
762
|
+
md.push('');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (reference?.unlockedBy) {
|
|
766
|
+
md.push('### What would make this run stronger');
|
|
767
|
+
md.push('');
|
|
768
|
+
md.push(reference.unlockedBy);
|
|
769
|
+
md.push('');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
md.push('---');
|
|
773
|
+
md.push('');
|
|
774
|
+
md.push(oneLine(verdict.summary ?? ''));
|
|
775
|
+
md.push('');
|
|
776
|
+
// The engine writes its own closing sentence assuming it was allowed to keep what it saw,
|
|
777
|
+
// because on a laptop it always is. On a build server it is not, and a paragraph saying
|
|
778
|
+
// "this run has been kept" when nothing was kept is exactly the quiet untruth this whole
|
|
779
|
+
// tool exists to stop. So it is corrected here, in the open, rather than edited out.
|
|
780
|
+
if (extra.remembered === false) {
|
|
781
|
+
md.push('Nothing from this run was stored. A build server never writes this product\'s record — only a run on the machine that owns the project does that — so anything above about this run being kept for next time does not apply here.');
|
|
782
|
+
md.push('');
|
|
783
|
+
}
|
|
784
|
+
md.push('_Stays Fixed reports. It never approves anything: only a person shipping can say what "working" means._');
|
|
785
|
+
md.push('');
|
|
786
|
+
|
|
787
|
+
const markdown = md.join('\n');
|
|
788
|
+
return { ok: verdict.ok === true && !blocked && !nothingToCompare, blocked, exitCode, headline, markdown, text: plainText(markdown) };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* @param {Finding} f
|
|
793
|
+
* @returns {string[]}
|
|
794
|
+
*/
|
|
795
|
+
function findingLines(f) {
|
|
796
|
+
/** @type {string[]} */
|
|
797
|
+
const out = [];
|
|
798
|
+
out.push(`- **${f.sealed ? `[${f.class}] ` : ''}${f.title}**`);
|
|
799
|
+
const example = f.differences?.[0];
|
|
800
|
+
if (example) {
|
|
801
|
+
const where = `\`${example.path}\``;
|
|
802
|
+
if (example.kind === 'appeared') out.push(` - ${where}: was not there before, and now it is ${show(example.candidate)}`);
|
|
803
|
+
else if (example.kind === 'vanished') out.push(` - ${where}: was ${show(example.reference)}, and now it is not there at all`);
|
|
804
|
+
else out.push(` - ${where}: was ${show(example.reference)}, now ${show(example.candidate)}`);
|
|
805
|
+
}
|
|
806
|
+
const n = f.count ?? f.differences?.length ?? 0;
|
|
807
|
+
if (n > 1) out.push(` - the same thing in ${n} places`);
|
|
808
|
+
if (f.why) out.push(` - ${f.why}`);
|
|
809
|
+
return out;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// Writing it down
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Append the report to the job summary, when the build server has one.
|
|
818
|
+
*
|
|
819
|
+
* Returns the file it wrote to, or null when there is nowhere to write. Null is a normal
|
|
820
|
+
* answer on GitLab and CircleCI, neither of which has a summary page.
|
|
821
|
+
*
|
|
822
|
+
* @param {CIReport} report
|
|
823
|
+
* @param {CIEnvironment} [env]
|
|
824
|
+
* @returns {Promise<string|null>}
|
|
825
|
+
*/
|
|
826
|
+
export async function writeJobSummary(report, env) {
|
|
827
|
+
const where = (env ?? detectCI()).summaryFile;
|
|
828
|
+
if (!where) return null;
|
|
829
|
+
try {
|
|
830
|
+
await fsp.appendFile(where, `${report.markdown}\n`);
|
|
831
|
+
return where;
|
|
832
|
+
} catch {
|
|
833
|
+
// A summary page is a nicety. Losing it must never cost the exit code, which is the
|
|
834
|
+
// half that actually stops the merge.
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Everything worth keeping from this run, in one folder a workflow can upload.
|
|
841
|
+
*
|
|
842
|
+
* WHAT IS IN HERE, HONESTLY: the verdict, the reference decision, the summary, and the
|
|
843
|
+
* store — which holds every observation both builds produced, as JSONL. That is the real
|
|
844
|
+
* evidence and it is enough to work out what happened after the runner is gone.
|
|
845
|
+
*
|
|
846
|
+
* WHAT IS NOT IN HERE: pictures. The engine writes its evidence images into a scratch
|
|
847
|
+
* folder and deletes that folder when the run ends, so by the time this is called they no
|
|
848
|
+
* longer exist. Saying so is better than shipping an empty folder called evidence.
|
|
849
|
+
*
|
|
850
|
+
* @param {{cwd?: string, dir?: string, verdict: CheckOutcome, reference: CIReference, report: CIReport, env?: CIEnvironment}} what
|
|
851
|
+
* @returns {Promise<string>}
|
|
852
|
+
*/
|
|
853
|
+
export async function saveEvidence(what) {
|
|
854
|
+
const cwd = path.resolve(what.cwd ?? process.cwd());
|
|
855
|
+
const dir = path.resolve(cwd, what.dir ?? path.join('.staysfixed', 'ci'));
|
|
856
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
857
|
+
await fsp.writeFile(path.join(dir, 'verdict.json'), `${JSON.stringify(what.verdict, null, 2)}\n`);
|
|
858
|
+
await fsp.writeFile(path.join(dir, 'reference.json'), `${JSON.stringify(what.reference, null, 2)}\n`);
|
|
859
|
+
await fsp.writeFile(path.join(dir, 'summary.md'), `${what.report.markdown}\n`);
|
|
860
|
+
if (what.env) await fsp.writeFile(path.join(dir, 'where-it-ran.json'), `${JSON.stringify(what.env, null, 2)}\n`);
|
|
861
|
+
await fsp.writeFile(
|
|
862
|
+
path.join(dir, 'README.md'),
|
|
863
|
+
[
|
|
864
|
+
'# What is in here',
|
|
865
|
+
'',
|
|
866
|
+
'- `summary.md` — the same report that was written into the job summary.',
|
|
867
|
+
'- `verdict.json` — everything the check concluded, including every gap in what it looked at.',
|
|
868
|
+
'- `reference.json` — what it compared against, how that was chosen, and what would have made it stronger.',
|
|
869
|
+
'- `where-it-ran.json` — the commit, the branch and the pull request this was.',
|
|
870
|
+
'',
|
|
871
|
+
'There are no pictures in here. The engine writes its evidence images into a scratch',
|
|
872
|
+
'folder and clears that folder when the run ends, so nothing is left to collect by the',
|
|
873
|
+
'time the job packs its results up. The observations in the store are the evidence.',
|
|
874
|
+
'',
|
|
875
|
+
].join('\n'),
|
|
876
|
+
);
|
|
877
|
+
return dir;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ---------------------------------------------------------------------------
|
|
881
|
+
// The whole thing
|
|
882
|
+
// ---------------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Work out the reference, run the check, write the report, hand back an exit code.
|
|
886
|
+
*
|
|
887
|
+
* This is what a workflow file calls, and it is one function on purpose: everything a
|
|
888
|
+
* build server needs to do is here, in this order, with no way to accidentally leave the
|
|
889
|
+
* exit code behind.
|
|
890
|
+
*
|
|
891
|
+
* `staysfixed ci` is not a command yet because the command table belongs to another file.
|
|
892
|
+
* When it becomes one, it is this function and nothing else.
|
|
893
|
+
*
|
|
894
|
+
* @param {{cwd?: string, env?: Env, against?: string, paired?: boolean, journeys?: string, only?: string[], product?: string, evidenceDir?: string, quiet?: boolean, remember?: boolean}} [opts]
|
|
895
|
+
* @returns {Promise<{exitCode: number, report: CIReport, reference: CIReference, verdict: CheckOutcome, evidence: string|null}>}
|
|
896
|
+
*/
|
|
897
|
+
export async function runCI(opts = {}) {
|
|
898
|
+
const cwd = path.resolve(opts.cwd ?? process.cwd());
|
|
899
|
+
const env = opts.env ?? process.env;
|
|
900
|
+
const where = detectCI(env);
|
|
901
|
+
const reference = await referenceForCI({ cwd, env, against: opts.against, product: opts.product });
|
|
902
|
+
|
|
903
|
+
const started = Date.now();
|
|
904
|
+
/** @type {CheckOutcome} */
|
|
905
|
+
const verdict = await check({
|
|
906
|
+
cwd,
|
|
907
|
+
product: opts.product,
|
|
908
|
+
// Null means "use whatever the project's own reference points at", which for the
|
|
909
|
+
// stored-record mode is exactly right and for every other mode never happens.
|
|
910
|
+
against: reference.against ?? undefined,
|
|
911
|
+
// A build server has a whole machine to itself and nothing else to do with it. Where the
|
|
912
|
+
// old build can be booted, boot it: the expensive answer is the only one worth blocking
|
|
913
|
+
// a merge on, and this is the one place where paying for it costs nobody any waiting.
|
|
914
|
+
paired: opts.paired ?? reference.paired,
|
|
915
|
+
journeys: opts.journeys,
|
|
916
|
+
only: opts.only,
|
|
917
|
+
// A pull request job must never write this product's record. Its observations were taken
|
|
918
|
+
// on a machine nobody will see again, off a branch nobody has merged, and letting them
|
|
919
|
+
// become "what the old build did" would move the standard sideways every time a runner
|
|
920
|
+
// changed. So this is off unless a job asks for it in so many words.
|
|
921
|
+
//
|
|
922
|
+
// The one job that should ask is a job on your main branch whose purpose is to LEAVE a
|
|
923
|
+
// record for later pull requests to compare against — see the caching section in
|
|
924
|
+
// docs/running-it-in-ci.md. That is the only shape where remembering is right, and it is
|
|
925
|
+
// a deliberate flag rather than a default because getting it the wrong way round turns
|
|
926
|
+
// every red pull request into the new definition of working.
|
|
927
|
+
remember: opts.remember === true,
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
const report = reportForCI(verdict, { reference, env: where, durationMs: Date.now() - started, remembered: opts.remember === true });
|
|
931
|
+
|
|
932
|
+
/** @type {string|null} */
|
|
933
|
+
let evidence = null;
|
|
934
|
+
try {
|
|
935
|
+
evidence = await saveEvidence({ cwd, dir: opts.evidenceDir, verdict, reference, report, env: where });
|
|
936
|
+
} catch {
|
|
937
|
+
// Losing the attachment must never change the answer.
|
|
938
|
+
}
|
|
939
|
+
await writeJobSummary(report, where);
|
|
940
|
+
|
|
941
|
+
if (opts.quiet !== true) process.stdout.write(`${report.text}\n`);
|
|
942
|
+
return { exitCode: report.exitCode, report, reference, verdict, evidence };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
// Small things
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* @param {string} cwd
|
|
951
|
+
* @param {string[]} args
|
|
952
|
+
* @returns {Promise<string|null>}
|
|
953
|
+
*/
|
|
954
|
+
async function git(cwd, args) {
|
|
955
|
+
try {
|
|
956
|
+
const { stdout } = await exec('git', args, { cwd, timeout: 20_000, maxBuffer: 8 * 1024 * 1024 });
|
|
957
|
+
const out = stdout.trim();
|
|
958
|
+
return out === '' ? null : out;
|
|
959
|
+
} catch {
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* A name a person typed, turned into the commit it means — or null if this checkout has never
|
|
966
|
+
* heard of it.
|
|
967
|
+
* @param {string} cwd
|
|
968
|
+
* @param {string} name
|
|
969
|
+
* @returns {Promise<string|null>}
|
|
970
|
+
*/
|
|
971
|
+
async function resolveCommit(cwd, name) {
|
|
972
|
+
return await git(cwd, ['rev-parse', '--verify', '--quiet', `${name}^{commit}`]);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* The most recent tag in this history, and what it points at.
|
|
977
|
+
* @param {string} cwd
|
|
978
|
+
* @returns {Promise<{name: string, sha: string}|null>}
|
|
979
|
+
*/
|
|
980
|
+
async function lastTag(cwd) {
|
|
981
|
+
// HEAD itself being tagged is the release-just-happened case, and comparing a build
|
|
982
|
+
// against itself proves nothing, so step back one commit before asking.
|
|
983
|
+
for (const from of ['HEAD^', 'HEAD']) {
|
|
984
|
+
const name = await git(cwd, ['describe', '--tags', '--abbrev=0', from]);
|
|
985
|
+
if (!name) continue;
|
|
986
|
+
const sha = await resolveCommit(cwd, name);
|
|
987
|
+
if (!sha) continue;
|
|
988
|
+
const head = await resolveCommit(cwd, 'HEAD');
|
|
989
|
+
if (sha === head) continue;
|
|
990
|
+
return { name, sha };
|
|
991
|
+
}
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* @param {string} cwd
|
|
997
|
+
* @returns {Promise<string|null>}
|
|
998
|
+
*/
|
|
999
|
+
async function productName(cwd) {
|
|
1000
|
+
try {
|
|
1001
|
+
const pkg = JSON.parse(await fsp.readFile(path.join(cwd, 'package.json'), 'utf8'));
|
|
1002
|
+
return typeof pkg?.name === 'string' ? pkg.name : path.basename(cwd);
|
|
1003
|
+
} catch {
|
|
1004
|
+
return path.basename(cwd);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Build ids made from a commit look like `git-a1b2c3d4e5f6`. Getting the commit back out of
|
|
1010
|
+
* one is how a reference set on another machine still names something this checkout has.
|
|
1011
|
+
* @param {string} buildId
|
|
1012
|
+
* @returns {string|null}
|
|
1013
|
+
*/
|
|
1014
|
+
function shaFromBuildId(buildId) {
|
|
1015
|
+
const m = /^git-([0-9a-f]{7,40})$/.exec(buildId ?? '');
|
|
1016
|
+
return m ? m[1] : null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* The file GitHub drops the whole event into. It is the only place the tip of the branch and
|
|
1021
|
+
* the base commit can be read from without asking the network.
|
|
1022
|
+
*
|
|
1023
|
+
* Read synchronously on purpose: `detectCI` is called from places that are not async, it
|
|
1024
|
+
* happens once, and it is a few kilobytes on a local disk.
|
|
1025
|
+
*
|
|
1026
|
+
* @param {string|undefined} file
|
|
1027
|
+
* @returns {Record<string, any>|null}
|
|
1028
|
+
*/
|
|
1029
|
+
function readEventFile(file) {
|
|
1030
|
+
if (!file) return null;
|
|
1031
|
+
try {
|
|
1032
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1033
|
+
} catch {
|
|
1034
|
+
// No event file, an unreadable one, or one this server writes in some other shape. Every
|
|
1035
|
+
// caller below already copes with not knowing, so this is a shrug and not a failure.
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* A commit that is all zeros means "there was nothing here before" — the first push to a new
|
|
1042
|
+
* branch. It is a real value in the environment and a useless one to compare against.
|
|
1043
|
+
* @param {string|null} sha
|
|
1044
|
+
* @returns {string|null}
|
|
1045
|
+
*/
|
|
1046
|
+
function usableSha(sha) {
|
|
1047
|
+
if (!sha) return null;
|
|
1048
|
+
return /^0+$/.test(sha) ? null : sha;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* @param {unknown} v
|
|
1053
|
+
* @returns {string|null}
|
|
1054
|
+
*/
|
|
1055
|
+
function text(v) {
|
|
1056
|
+
if (v === null || v === undefined) return null;
|
|
1057
|
+
const s = String(v);
|
|
1058
|
+
return s === '' ? null : s;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* @param {string|undefined} url
|
|
1063
|
+
* @returns {string|null}
|
|
1064
|
+
*/
|
|
1065
|
+
function lastSegment(url) {
|
|
1066
|
+
if (!url) return null;
|
|
1067
|
+
const parts = url.split('/').filter((p) => p !== '');
|
|
1068
|
+
return parts.length > 0 ? parts[parts.length - 1] : null;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** @param {string} sha */
|
|
1072
|
+
function short(sha) {
|
|
1073
|
+
return sha.slice(0, 7);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* @param {number} n
|
|
1078
|
+
* @param {string} one
|
|
1079
|
+
* @param {string} many
|
|
1080
|
+
*/
|
|
1081
|
+
function count(n, one, many) {
|
|
1082
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Time as a plain total, never a clock range.
|
|
1087
|
+
* @param {number} ms
|
|
1088
|
+
* @returns {string}
|
|
1089
|
+
*/
|
|
1090
|
+
function minutes(ms) {
|
|
1091
|
+
const secs = Math.round(ms / 1000);
|
|
1092
|
+
if (secs < 90) return `about ${count(secs, 'second', 'seconds')}`;
|
|
1093
|
+
const mins = Math.round(secs / 60);
|
|
1094
|
+
return `about ${count(mins, 'minute', 'minutes')}`;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* @param {string} label
|
|
1099
|
+
* @param {string} value
|
|
1100
|
+
*/
|
|
1101
|
+
function row(label, value) {
|
|
1102
|
+
return `| ${label} | ${value.split('|').join('\\|')} |`;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* @param {unknown} value
|
|
1107
|
+
* @returns {string}
|
|
1108
|
+
*/
|
|
1109
|
+
function show(value) {
|
|
1110
|
+
const t = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
|
|
1111
|
+
// A real newline inside a markdown list item ends the item, so the rest of the value
|
|
1112
|
+
// silently falls out of the report. Writing them the way code writes them keeps a
|
|
1113
|
+
// difference like "ready" becoming "READY" on one readable line.
|
|
1114
|
+
const flat = t.split('\r\n').join('\\n').split('\n').join('\\n').split('\t').join('\\t');
|
|
1115
|
+
const trimmed = flat.length > 80 ? `${flat.slice(0, 77)}…` : flat;
|
|
1116
|
+
return `\`${trimmed.split('`').join("'")}\``;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* @param {import('./types.js').BuildFingerprint|undefined} build
|
|
1121
|
+
* @returns {string}
|
|
1122
|
+
*/
|
|
1123
|
+
function nameOfBuild(build) {
|
|
1124
|
+
if (!build) return 'the build with no name';
|
|
1125
|
+
if (build.version) return build.version;
|
|
1126
|
+
if (build.gitSha) return build.gitSha.slice(0, 7);
|
|
1127
|
+
return build.id || 'the build with no name';
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* @param {string} s
|
|
1132
|
+
* @returns {string}
|
|
1133
|
+
*/
|
|
1134
|
+
function oneLine(s) {
|
|
1135
|
+
return s.split(/\s+/).join(' ').trim();
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* The same report, with the markdown taken off, for a job log where markdown is just noise.
|
|
1140
|
+
* @param {string} markdown
|
|
1141
|
+
* @returns {string}
|
|
1142
|
+
*/
|
|
1143
|
+
function plainText(markdown) {
|
|
1144
|
+
return markdown
|
|
1145
|
+
.split('\n')
|
|
1146
|
+
.filter((line) => !/^\|\s*-+\s*\|/.test(line))
|
|
1147
|
+
.map((line) => {
|
|
1148
|
+
if (/^\|/.test(line)) {
|
|
1149
|
+
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
|
|
1150
|
+
return cells.filter((c) => c !== '').join(': ');
|
|
1151
|
+
}
|
|
1152
|
+
return line
|
|
1153
|
+
.replace(/^#+\s*/, '')
|
|
1154
|
+
.replace(/^>\s?/, '')
|
|
1155
|
+
.split('**').join('')
|
|
1156
|
+
.split('`').join('')
|
|
1157
|
+
.replace(/^_(.*)_$/, '$1');
|
|
1158
|
+
})
|
|
1159
|
+
.join('\n')
|
|
1160
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ---------------------------------------------------------------------------
|
|
1164
|
+
// Running this file directly
|
|
1165
|
+
// ---------------------------------------------------------------------------
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* `node src/v2/ci.js` runs the whole thing and exits with the code that stops the merge.
|
|
1169
|
+
*
|
|
1170
|
+
* It is here rather than in the command table because that table lives in another file. A
|
|
1171
|
+
* workflow can call this path directly today and switch to `staysfixed ci` the day it exists,
|
|
1172
|
+
* with no change to what happens.
|
|
1173
|
+
*
|
|
1174
|
+
* @param {string[]} argv
|
|
1175
|
+
* @returns {Promise<number>}
|
|
1176
|
+
*/
|
|
1177
|
+
export async function main(argv) {
|
|
1178
|
+
/** @type {Record<string, string|boolean>} */
|
|
1179
|
+
const flags = {};
|
|
1180
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1181
|
+
const arg = argv[i];
|
|
1182
|
+
if (!arg.startsWith('--')) continue;
|
|
1183
|
+
const [name, inline] = arg.slice(2).split('=');
|
|
1184
|
+
if (inline !== undefined) flags[name] = inline;
|
|
1185
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) flags[name] = argv[(i += 1)];
|
|
1186
|
+
else flags[name] = true;
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
const result = await runCI({
|
|
1190
|
+
cwd: typeof flags.cwd === 'string' ? flags.cwd : undefined,
|
|
1191
|
+
against: typeof flags.against === 'string' ? flags.against : undefined,
|
|
1192
|
+
journeys: typeof flags.journeys === 'string' ? flags.journeys : undefined,
|
|
1193
|
+
product: typeof flags.product === 'string' ? flags.product : undefined,
|
|
1194
|
+
evidenceDir: typeof flags.evidence === 'string' ? flags.evidence : undefined,
|
|
1195
|
+
paired: flags.paired === true ? true : undefined,
|
|
1196
|
+
remember: flags.remember === true,
|
|
1197
|
+
});
|
|
1198
|
+
return result.exitCode;
|
|
1199
|
+
} catch (e) {
|
|
1200
|
+
// Anything that reaches here never ran a check, so it is exit 2. Never 1, which a reader
|
|
1201
|
+
// would take to mean the product changed, and never 0.
|
|
1202
|
+
process.stderr.write(`Stays Fixed could not run on this build server. ${messageOf(e)}\n`);
|
|
1203
|
+
return EXIT.error;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
1208
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
1209
|
+
}
|