workspace-conformance 1.1.0 → 1.2.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 +64 -6
- package/dist/cli.js +1670 -205
- package/dist/index.cjs +1485 -10
- package/dist/index.d.cts +503 -1
- package/dist/index.d.ts +503 -1
- package/dist/index.js +1466 -12
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,98 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { dirname, extname, join, posix, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
+
import { z } from "zod";
|
|
4
5
|
import { existsSync } from "node:fs";
|
|
5
6
|
import { escapePath, glob, isDynamicPattern } from "tinyglobby";
|
|
6
7
|
import { Node, Project } from "ts-morph";
|
|
7
8
|
import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
|
|
8
9
|
import { readFile, realpath } from "node:fs/promises";
|
|
9
|
-
import { parse } from "yaml";
|
|
10
10
|
import { execFile } from "node:child_process";
|
|
11
|
+
import { LineCounter, isMap, isNode, isScalar, isSeq, parse, parseDocument } from "yaml";
|
|
11
12
|
import { findNodeAtLocation, parse as parse$1, parseTree, printParseErrorCode } from "jsonc-parser";
|
|
12
13
|
import { parse as parse$2 } from "smol-toml";
|
|
13
14
|
import { defineSection, layoutSection, loadSection } from "@exadev/config";
|
|
14
|
-
|
|
15
|
+
//#region src/errors.ts
|
|
16
|
+
/**
|
|
17
|
+
* A problem with how the tool was used or configured rather than a finding about the workspace: an unknown or disabled check, a missing or invalid section, a layout that cannot be resolved, a repository that is not a git checkout. The command line reports it and exits 2.
|
|
18
|
+
*/
|
|
19
|
+
var ConformanceError = class extends Error {
|
|
20
|
+
constructor(message, options) {
|
|
21
|
+
super(message, options);
|
|
22
|
+
this.name = "ConformanceError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/github.ts
|
|
27
|
+
const repositorySchema = z.object({
|
|
28
|
+
default_branch: z.string(),
|
|
29
|
+
allow_rebase_merge: z.optional(z.boolean()),
|
|
30
|
+
allow_squash_merge: z.optional(z.boolean()),
|
|
31
|
+
allow_merge_commit: z.optional(z.boolean())
|
|
32
|
+
});
|
|
33
|
+
const rulesSchema = z.array(z.looseObject({
|
|
34
|
+
type: z.string(),
|
|
35
|
+
parameters: z.optional(z.looseObject({
|
|
36
|
+
required_status_checks: z.optional(z.array(z.object({ context: z.string() }))),
|
|
37
|
+
strict_required_status_checks_policy: z.optional(z.boolean()),
|
|
38
|
+
required_review_thread_resolution: z.optional(z.boolean()),
|
|
39
|
+
allowed_merge_methods: z.optional(z.array(z.string()))
|
|
40
|
+
}))
|
|
41
|
+
}));
|
|
42
|
+
const API_ROOT = "https://api.github.com";
|
|
43
|
+
const API_VERSION = "2022-11-28";
|
|
44
|
+
/**
|
|
45
|
+
* How many rules the API returns in one page, its maximum.
|
|
46
|
+
*/
|
|
47
|
+
const PAGE_SIZE = 100;
|
|
48
|
+
/**
|
|
49
|
+
* A {@link GitHubClient} over the REST API.
|
|
50
|
+
*/
|
|
51
|
+
function createGitHubClient(options) {
|
|
52
|
+
const request = options.fetch ?? fetch;
|
|
53
|
+
const root = options.apiUrl ?? API_ROOT;
|
|
54
|
+
async function get(path) {
|
|
55
|
+
const response = await request(`${root}${path}`, { headers: {
|
|
56
|
+
Accept: "application/vnd.github+json",
|
|
57
|
+
Authorization: `Bearer ${options.token}`,
|
|
58
|
+
"X-GitHub-Api-Version": API_VERSION
|
|
59
|
+
} });
|
|
60
|
+
if (!response.ok) throw new ConformanceError(`GET ${path} failed: ${String(response.status)} ${response.statusText}`);
|
|
61
|
+
return response.json();
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
repository: async (slug) => {
|
|
65
|
+
const parsed = repositorySchema.parse(await get(`/repos/${slug.owner}/${slug.name}`));
|
|
66
|
+
const { allow_rebase_merge: allowRebaseMerge, allow_squash_merge: allowSquashMerge, allow_merge_commit: allowMergeCommit } = parsed;
|
|
67
|
+
if (allowRebaseMerge === void 0 || allowSquashMerge === void 0 || allowMergeCommit === void 0) throw new ConformanceError(`GET /repos/${slug.owner}/${slug.name} did not report the merge-method settings, which GitHub shows only to a token with write access to the repository`);
|
|
68
|
+
return {
|
|
69
|
+
defaultBranch: parsed.default_branch,
|
|
70
|
+
allowRebaseMerge,
|
|
71
|
+
allowSquashMerge,
|
|
72
|
+
allowMergeCommit
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
branchRules: async (slug, branch) => {
|
|
76
|
+
const rules = [];
|
|
77
|
+
for (let page = 1, more = true; more; page += 1) {
|
|
78
|
+
const found = rulesSchema.parse(await get(`/repos/${slug.owner}/${slug.name}/rules/branches/${encodeURIComponent(branch)}?per_page=${String(PAGE_SIZE)}&page=${String(page)}`));
|
|
79
|
+
rules.push(...found);
|
|
80
|
+
more = found.length === PAGE_SIZE;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
requiredStatusChecks: rules.filter((rule) => rule.type === "required_status_checks").map((rule) => ({
|
|
84
|
+
contexts: (rule.parameters?.required_status_checks ?? []).map((check) => check.context),
|
|
85
|
+
strict: rule.parameters?.strict_required_status_checks_policy === true
|
|
86
|
+
})),
|
|
87
|
+
pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
|
|
88
|
+
requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
|
|
89
|
+
allowedMergeMethods: rule.parameters?.allowed_merge_methods
|
|
90
|
+
}))
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
15
96
|
//#region src/files.ts
|
|
16
97
|
/**
|
|
17
98
|
* Directories no check descends into: installed dependencies are not the workspace's own files.
|
|
@@ -77,17 +158,6 @@ function relativePosix(cwd, absolute) {
|
|
|
77
158
|
return toPosix(relative(cwd, absolute));
|
|
78
159
|
}
|
|
79
160
|
//#endregion
|
|
80
|
-
//#region src/errors.ts
|
|
81
|
-
/**
|
|
82
|
-
* A problem with how the tool was used or configured rather than a finding about the workspace: an unknown or disabled check, a missing or invalid section, a layout that cannot be resolved, a repository that is not a git checkout. The command line reports it and exits 2.
|
|
83
|
-
*/
|
|
84
|
-
var ConformanceError = class extends Error {
|
|
85
|
-
constructor(message, options) {
|
|
86
|
-
super(message, options);
|
|
87
|
-
this.name = "ConformanceError";
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
//#endregion
|
|
91
161
|
//#region src/template.ts
|
|
92
162
|
const PLACEHOLDER = /\{([a-z]+)\}/gu;
|
|
93
163
|
/**
|
|
@@ -249,7 +319,7 @@ function aliasNamed(typeName) {
|
|
|
249
319
|
/**
|
|
250
320
|
* Why a command type is not derived from a schema, or `undefined` when it is. `followed` holds the aliases already followed from the exported type, so a chain of aliases is followed to its end and a loop of them is not followed twice.
|
|
251
321
|
*/
|
|
252
|
-
function problem(declaration, name, inferences, followed = /* @__PURE__ */ new Set()) {
|
|
322
|
+
function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new Set()) {
|
|
253
323
|
if (Node.isInterfaceDeclaration(declaration)) return {
|
|
254
324
|
reason: "hand-written-interface",
|
|
255
325
|
message: `${name} is a hand-written interface; derive the command from its schema`
|
|
@@ -266,7 +336,7 @@ function problem(declaration, name, inferences, followed = /* @__PURE__ */ new S
|
|
|
266
336
|
};
|
|
267
337
|
}
|
|
268
338
|
const alias = aliasNamed(written.getTypeName());
|
|
269
|
-
if (alias !== void 0 && !followed.has(alias)) return problem(alias, name, inferences, /* @__PURE__ */ new Set([...followed, alias]));
|
|
339
|
+
if (alias !== void 0 && !followed.has(alias)) return problem$1(alias, name, inferences, /* @__PURE__ */ new Set([...followed, alias]));
|
|
270
340
|
}
|
|
271
341
|
return {
|
|
272
342
|
reason: "hand-written-alias",
|
|
@@ -290,7 +360,7 @@ const commandTypes = async ({ cwd, options }) => {
|
|
|
290
360
|
for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow(resolve(cwd, file)))) {
|
|
291
361
|
if (excluded.has(name) || seen.has(declaration)) continue;
|
|
292
362
|
seen.add(declaration);
|
|
293
|
-
const found = problem(declaration, name, inferences);
|
|
363
|
+
const found = problem$1(declaration, name, inferences);
|
|
294
364
|
if (found !== void 0) violations.push({
|
|
295
365
|
code: `command-types/${found.reason}`,
|
|
296
366
|
message: found.message,
|
|
@@ -371,175 +441,1439 @@ async function readObject(cwd, file, configFiles) {
|
|
|
371
441
|
return config;
|
|
372
442
|
}
|
|
373
443
|
/**
|
|
374
|
-
* The commit types the conventional presets know, which is the `type-enum` of `@commitlint/config-conventional` and the type list the conventional changelog presets label. Their release behaviour is the preset's own, so a preset type needs no release rule or changelog section to be accounted for; a type outside this list does.
|
|
444
|
+
* The commit types the conventional presets know, which is the `type-enum` of `@commitlint/config-conventional` and the type list the conventional changelog presets label. Their release behaviour is the preset's own, so a preset type needs no release rule or changelog section to be accounted for; a type outside this list does.
|
|
445
|
+
*/
|
|
446
|
+
const PRESET_COMMIT_TYPES = [
|
|
447
|
+
"build",
|
|
448
|
+
"chore",
|
|
449
|
+
"ci",
|
|
450
|
+
"docs",
|
|
451
|
+
"feat",
|
|
452
|
+
"fix",
|
|
453
|
+
"perf",
|
|
454
|
+
"refactor",
|
|
455
|
+
"revert",
|
|
456
|
+
"style",
|
|
457
|
+
"test"
|
|
458
|
+
];
|
|
459
|
+
function isCustom(type) {
|
|
460
|
+
return !PRESET_COMMIT_TYPES.includes(type);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The commit types commitlint accepts are accounted for in the release config. A type no preset knows must have a release rule and, when the config lists changelog sections (`presetConfig.types` of the release notes generator), a section, or it can be committed and never released or shown; a release rule or changelog section for a type commitlint rejects can never apply. Types are enumerated only by a `type-enum` rule of the form `[level, 'always', [types]]`; a `'never'` rule forbids the types it lists and enumerates none, and a rule that is off (level 0) accepts every type. A preset type may be left out of either list: leaving out a type that does not release is how a release rule list is normally written.
|
|
464
|
+
*
|
|
465
|
+
* It compares only what is written. The check has nothing to say when the commit types are the preset's and every listed type is one of them. A commitlint config that does not set `type-enum` while the release config lists a type no preset knows is a violation, since the commit types cannot then be read. Both configs are evaluated, so a list may be derived from one shared constant.
|
|
466
|
+
*/
|
|
467
|
+
const commitTypes = async ({ cwd, options, configFiles }) => {
|
|
468
|
+
const commitlintFile = options.commitlint ?? DEFAULT_COMMITLINT_CONFIG;
|
|
469
|
+
const releaseFile = options.release ?? DEFAULT_RELEASE_CONFIG;
|
|
470
|
+
const commitlint = await readObject(cwd, commitlintFile, configFiles);
|
|
471
|
+
const release = await readObject(cwd, releaseFile, configFiles);
|
|
472
|
+
const violations = [];
|
|
473
|
+
if (commitlint === void 0) violations.push({
|
|
474
|
+
code: "commit-types/missing-config",
|
|
475
|
+
message: `${commitlintFile} does not exist`,
|
|
476
|
+
file: commitlintFile
|
|
477
|
+
});
|
|
478
|
+
if (release === void 0) violations.push({
|
|
479
|
+
code: "commit-types/missing-config",
|
|
480
|
+
message: `${releaseFile} does not exist`,
|
|
481
|
+
file: releaseFile
|
|
482
|
+
});
|
|
483
|
+
if (commitlint === void 0 || release === void 0) return violations;
|
|
484
|
+
const releaseRules = typesOfEntries(pluginOptions(release, COMMIT_ANALYZER)?.["releaseRules"], releaseFile, "releaseRules");
|
|
485
|
+
const presetConfig = pluginOptions(release, RELEASE_NOTES_GENERATOR)?.["presetConfig"];
|
|
486
|
+
const changelog = typesOfEntries(isRecord(presetConfig) ? presetConfig["types"] : void 0, releaseFile, "presetConfig.types");
|
|
487
|
+
const accepted = commitTypeRule(commitlint, commitlintFile);
|
|
488
|
+
if (accepted === void 0) {
|
|
489
|
+
const unknown = [releaseRules, changelog].flatMap((list) => list ?? []).filter(isCustom);
|
|
490
|
+
if (unknown.length > 0) violations.push({
|
|
491
|
+
code: "commit-types/no-type-enum",
|
|
492
|
+
message: `${releaseFile} lists ${unknown.map((type) => `'${type}'`).join(", ")}, which no preset knows, but ${commitlintFile} sets no 'type-enum' rule, so the commit types cannot be read`,
|
|
493
|
+
file: commitlintFile
|
|
494
|
+
});
|
|
495
|
+
return violations;
|
|
496
|
+
}
|
|
497
|
+
const lists = [{
|
|
498
|
+
listed: releaseRules,
|
|
499
|
+
required: true,
|
|
500
|
+
noun: "release rule",
|
|
501
|
+
absent: "no-release-rule",
|
|
502
|
+
extra: "release-rule-not-a-commit-type"
|
|
503
|
+
}, {
|
|
504
|
+
listed: changelog,
|
|
505
|
+
required: changelog !== void 0,
|
|
506
|
+
noun: "changelog section",
|
|
507
|
+
absent: "no-changelog-section",
|
|
508
|
+
extra: "changelog-section-not-a-commit-type"
|
|
509
|
+
}];
|
|
510
|
+
for (const { listed, required, noun, absent, extra } of lists) {
|
|
511
|
+
for (const type of required ? (accepted.enumerated ?? []).filter(isCustom) : []) if (listed?.includes(type) !== true) violations.push({
|
|
512
|
+
code: `commit-types/${absent}`,
|
|
513
|
+
message: `'${type}' is a commit type in ${commitlintFile} that no preset knows, and ${releaseFile} has no ${noun} for it`,
|
|
514
|
+
file: releaseFile
|
|
515
|
+
});
|
|
516
|
+
for (const type of (listed ?? []).filter((candidate) => !accepted.accepts(candidate))) violations.push({
|
|
517
|
+
code: `commit-types/${extra}`,
|
|
518
|
+
message: `'${type}' has a ${noun} in ${releaseFile} but is not a commit type in ${commitlintFile}`,
|
|
519
|
+
file: commitlintFile
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
return violations;
|
|
523
|
+
};
|
|
524
|
+
//#endregion
|
|
525
|
+
//#region src/checks/dockerfile-package-manager.ts
|
|
526
|
+
const DEFAULT_DOCKERFILES = [
|
|
527
|
+
"**/Dockerfile",
|
|
528
|
+
"**/Dockerfile.*",
|
|
529
|
+
"**/*.Dockerfile"
|
|
530
|
+
];
|
|
531
|
+
const DEFAULT_PACKAGE_JSON = "package.json";
|
|
532
|
+
/**
|
|
533
|
+
* A package manager and a version, `pnpm@12.4.1` or `pnpm@12.4.1+sha512.abc`, as `packageManager` and corepack write them.
|
|
534
|
+
*/
|
|
535
|
+
const PACKAGE_MANAGER = /^([a-z]+)@([^+\s]+)/u;
|
|
536
|
+
/**
|
|
537
|
+
* A variable reference in a version: `$NAME` or `${NAME}`, capturing the name.
|
|
538
|
+
*/
|
|
539
|
+
const VARIABLE = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu;
|
|
540
|
+
/**
|
|
541
|
+
* `<manager>@<version>` wherever a Dockerfile installs or activates a package manager. The manager is not the end of a longer name (`create-pnpm`, `@scope/pnpm`). The version is what a version is made of, plus variable references, and stops at anything else, such as a comma or a quote; a `+` suffix such as `+sha512.abc` is not part of it.
|
|
542
|
+
*/
|
|
543
|
+
const PIN = /(?<![\w./@-])(pnpm|yarn|npm)@((?:\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$[A-Za-z_][A-Za-z0-9_]*|[0-9A-Za-z._-])+)/gu;
|
|
544
|
+
const INSTRUCTION = /^\s*(ARG|ENV)\s+(.*)$/iu;
|
|
545
|
+
const ASSIGNMENT = /([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S*)/gu;
|
|
546
|
+
function unquoted(value) {
|
|
547
|
+
return value.replace(/^(["'])(.*)\1$/u, "$2");
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* The variables an `ARG` or `ENV` instruction sets, with the value it gives each, or `undefined` for an `ARG` without a default. `ARG NAME=value`, `ENV NAME=value NAME2=value2` and `ENV NAME value` are read; any other line sets nothing.
|
|
551
|
+
*/
|
|
552
|
+
function variablesSetBy(line) {
|
|
553
|
+
const [, instruction, body] = INSTRUCTION.exec(line) ?? [];
|
|
554
|
+
const variables = /* @__PURE__ */ new Map();
|
|
555
|
+
if (instruction === void 0 || body === void 0) return variables;
|
|
556
|
+
for (const [, name, value] of body.matchAll(ASSIGNMENT)) if (name !== void 0 && value !== void 0) variables.set(name, unquoted(value));
|
|
557
|
+
if (variables.size === 0) {
|
|
558
|
+
const [name, ...value] = body.trim().split(/\s+/u);
|
|
559
|
+
if (name !== void 0 && name !== "") variables.set(name, instruction.toUpperCase() === "ENV" && value.length > 0 ? unquoted(value.join(" ")) : void 0);
|
|
560
|
+
}
|
|
561
|
+
return variables;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* The version a pin names once the variables it refers to are replaced by the values in effect, or `undefined` when a variable has no value there (an `ARG` without a default, or a variable this file does not set) or its value refers to another variable, which makes the version unknowable from the file. A `+sha` suffix is dropped.
|
|
565
|
+
*/
|
|
566
|
+
function versionOf(token, variables) {
|
|
567
|
+
const unknown = [];
|
|
568
|
+
const replaced = token.replace(VARIABLE, (_reference, variable) => {
|
|
569
|
+
const value = variables.get(variable);
|
|
570
|
+
if (value === void 0 || value.includes("$")) {
|
|
571
|
+
unknown.push(variable);
|
|
572
|
+
return "";
|
|
573
|
+
}
|
|
574
|
+
return value;
|
|
575
|
+
});
|
|
576
|
+
return unknown.length === 0 ? replaced.split("+")[0] : void 0;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Every pin of the package manager that `packageManager` names, in each Dockerfile, is the version `packageManager` names, with any `+sha` integrity suffix ignored on both sides. Pins of other package managers are not the workspace's package manager and are left alone.
|
|
580
|
+
*
|
|
581
|
+
* It reads `<manager>@<version>` as written on a line that is not a comment. A version that refers to a variable is compared with the value the file gives that variable by an `ARG` or `ENV` before the pin (the latest such value, whichever build stage it was in); one whose variable has no value in the file is not compared, since the build supplies it.
|
|
582
|
+
*/
|
|
583
|
+
const dockerfilePackageManager = async ({ cwd, options }) => {
|
|
584
|
+
const packageJson = options.packageJson ?? DEFAULT_PACKAGE_JSON;
|
|
585
|
+
const manifest = JSON.parse(await readFile(resolve(cwd, packageJson), "utf8"));
|
|
586
|
+
const declared = isRecord(manifest) && typeof manifest["packageManager"] === "string" ? PACKAGE_MANAGER.exec(manifest["packageManager"]) : null;
|
|
587
|
+
const name = declared?.[1];
|
|
588
|
+
const version = declared?.[2];
|
|
589
|
+
if (name === void 0 || version === void 0) return [{
|
|
590
|
+
code: "dockerfile-package-manager/no-package-manager",
|
|
591
|
+
message: `${packageJson} has no valid 'packageManager' field to compare Dockerfile pins with`,
|
|
592
|
+
file: packageJson
|
|
593
|
+
}];
|
|
594
|
+
const violations = [];
|
|
595
|
+
for (const file of await findFiles(cwd, [...options.dockerfiles ?? DEFAULT_DOCKERFILES, ...excludedFrom(options.exclude)])) {
|
|
596
|
+
const variables = /* @__PURE__ */ new Map();
|
|
597
|
+
(await readFile(resolve(cwd, file), "utf8")).split("\n").forEach((text, index) => {
|
|
598
|
+
if (text.trimStart().startsWith("#")) return;
|
|
599
|
+
for (const [variable, value] of variablesSetBy(text)) variables.set(variable, value);
|
|
600
|
+
for (const match of text.matchAll(PIN)) {
|
|
601
|
+
const pinned = versionOf(String(match[2]), variables);
|
|
602
|
+
if (match[1] === name && pinned !== void 0 && pinned !== version) violations.push({
|
|
603
|
+
code: "dockerfile-package-manager/version-mismatch",
|
|
604
|
+
message: `${file} pins ${name}@${pinned} but packageManager in ${packageJson} is ${name}@${version}`,
|
|
605
|
+
file,
|
|
606
|
+
location: {
|
|
607
|
+
line: index + 1,
|
|
608
|
+
column: match.index + 1
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
return violations;
|
|
615
|
+
};
|
|
616
|
+
async function git(cwd, args) {
|
|
617
|
+
return new Promise((resolve, reject) => {
|
|
618
|
+
execFile("git", [...args], {
|
|
619
|
+
cwd,
|
|
620
|
+
encoding: "utf8"
|
|
621
|
+
}, (error, stdout) => {
|
|
622
|
+
if (error === null) resolve(stdout);
|
|
623
|
+
else reject(new ConformanceError(`git ${args.join(" ")} failed in ${cwd}; this check reads the git index, so it needs a git working tree`, { cause: error }));
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* The index entry for `path` (relative to `cwd`), or `undefined` when git does not track it.
|
|
629
|
+
*/
|
|
630
|
+
async function indexEntry(cwd, path) {
|
|
631
|
+
const listing = await git(cwd, [
|
|
632
|
+
"ls-files",
|
|
633
|
+
"--stage",
|
|
634
|
+
"--",
|
|
635
|
+
path
|
|
636
|
+
]);
|
|
637
|
+
const match = /^(\d+) ([0-9a-f]+) \d\t/u.exec(listing);
|
|
638
|
+
const mode = match?.[1];
|
|
639
|
+
const blob = match?.[2];
|
|
640
|
+
return mode === void 0 || blob === void 0 ? void 0 : {
|
|
641
|
+
mode,
|
|
642
|
+
blob
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* The content of a blob. For a symbolic link it is the link's target.
|
|
647
|
+
*/
|
|
648
|
+
async function blobContent(cwd, blob) {
|
|
649
|
+
return git(cwd, [
|
|
650
|
+
"cat-file",
|
|
651
|
+
"blob",
|
|
652
|
+
blob
|
|
653
|
+
]);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* The URL of the remote `name` as configured, before any `url.<base>.insteadOf` rewriting (which `git remote get-url` applies and which can turn a GitHub address into a host alias), or `undefined` when the working tree has no such remote.
|
|
657
|
+
*/
|
|
658
|
+
async function remoteUrl(cwd, name) {
|
|
659
|
+
if (!(await git(cwd, ["remote"])).split("\n").includes(name)) return;
|
|
660
|
+
return (await git(cwd, [
|
|
661
|
+
"config",
|
|
662
|
+
"--get",
|
|
663
|
+
`remote.${name}.url`
|
|
664
|
+
])).trim();
|
|
665
|
+
}
|
|
666
|
+
//#endregion
|
|
667
|
+
//#region src/settings/repository.ts
|
|
668
|
+
const SLUG = /^([^/\s]+)\/([^/\s]+)$/u;
|
|
669
|
+
const REMOTE = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/u;
|
|
670
|
+
const REMOTE_NAME = "origin";
|
|
671
|
+
/**
|
|
672
|
+
* The repository the settings checks read: `repository` from the options, else the one the `origin` remote of `cwd` points at.
|
|
673
|
+
*/
|
|
674
|
+
async function repositoryOf(cwd, options) {
|
|
675
|
+
if (options.repository !== void 0) {
|
|
676
|
+
const [, owner, name] = SLUG.exec(options.repository) ?? [];
|
|
677
|
+
if (owner === void 0 || name === void 0) throw new ConformanceError(`the repository '${options.repository}' is not written owner/name`);
|
|
678
|
+
return {
|
|
679
|
+
owner,
|
|
680
|
+
name
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const url = await remoteUrl(cwd, REMOTE_NAME);
|
|
684
|
+
const [, owner, name] = REMOTE.exec(url ?? "") ?? [];
|
|
685
|
+
if (owner === void 0 || name === void 0) throw new ConformanceError(`${cwd} has no '${REMOTE_NAME}' remote on github.com; set the repository option`);
|
|
686
|
+
return {
|
|
687
|
+
owner,
|
|
688
|
+
name
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* The branch whose rules are read: `branch` from the options, else the repository's default branch.
|
|
693
|
+
*/
|
|
694
|
+
async function branchOf(github, slug, options) {
|
|
695
|
+
return options.branch ?? (await github.repository(slug)).defaultBranch;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* What a violation of a settings check names as its file.
|
|
699
|
+
*/
|
|
700
|
+
function repositoryFile(slug) {
|
|
701
|
+
return `${slug.owner}/${slug.name}`;
|
|
702
|
+
}
|
|
703
|
+
//#endregion
|
|
704
|
+
//#region src/checks/settings-merge-methods.ts
|
|
705
|
+
const METHODS = [
|
|
706
|
+
"rebase",
|
|
707
|
+
"squash",
|
|
708
|
+
"merge"
|
|
709
|
+
];
|
|
710
|
+
/**
|
|
711
|
+
* Only one merge method is available for a pull request into the branch, `rebase` unless the options say otherwise. A method is available when the repository allows it and no `pull_request` rule that applies to the branch leaves it out of its allowed methods.
|
|
712
|
+
*
|
|
713
|
+
* Limits: it reads the repository settings and the rules of rulesets that apply to the branch; classic branch protection and a merge queue's own merge method are not read, so a repository whose queue owns the strategy is judged by what it still allows for a direct merge.
|
|
714
|
+
*/
|
|
715
|
+
const settingsMergeMethods = async ({ cwd, options, github }) => {
|
|
716
|
+
const slug = await repositoryOf(cwd, options);
|
|
717
|
+
const file = repositoryFile(slug);
|
|
718
|
+
const settings = await github.repository(slug);
|
|
719
|
+
const rules = await github.branchRules(slug, await branchOf(github, slug, options));
|
|
720
|
+
const wanted = options.allowed ?? "rebase";
|
|
721
|
+
const repositoryAllows = {
|
|
722
|
+
rebase: settings.allowRebaseMerge,
|
|
723
|
+
squash: settings.allowSquashMerge,
|
|
724
|
+
merge: settings.allowMergeCommit
|
|
725
|
+
};
|
|
726
|
+
const available = METHODS.filter((method) => repositoryAllows[method] && rules.pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method)));
|
|
727
|
+
const violations = available.filter((method) => method !== wanted).map((method) => ({
|
|
728
|
+
code: "settings-merge-methods/method-enabled",
|
|
729
|
+
message: `${file} allows ${method} merges; only ${wanted} should be allowed`,
|
|
730
|
+
file
|
|
731
|
+
}));
|
|
732
|
+
if (!available.includes(wanted)) violations.push({
|
|
733
|
+
code: "settings-merge-methods/method-unavailable",
|
|
734
|
+
message: `${file} does not allow ${wanted} merges`,
|
|
735
|
+
file
|
|
736
|
+
});
|
|
737
|
+
return violations;
|
|
738
|
+
};
|
|
739
|
+
//#endregion
|
|
740
|
+
//#region src/workflows/model.ts
|
|
741
|
+
/**
|
|
742
|
+
* The access `permissions` grants to `scope`, or `undefined` when the permissions are not declared and the repository default applies.
|
|
743
|
+
*/
|
|
744
|
+
function accessOf(permissions, scope) {
|
|
745
|
+
if (permissions.kind === "default") return;
|
|
746
|
+
return permissions.kind === "all" ? permissions.access : permissions.scopes.get(scope) ?? "none";
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* The text of a scalar value; `undefined` for anything that is not a string, number or boolean.
|
|
750
|
+
*/
|
|
751
|
+
function scalarText(value) {
|
|
752
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : void 0;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Where a node of the document is: the key for a mapping entry, the item for a sequence entry, else the node itself. Positions are counted from 1.
|
|
756
|
+
*/
|
|
757
|
+
function locationAt(document, lineCounter, path) {
|
|
758
|
+
const parent = path.length === 0 ? document.contents : document.getIn(path.slice(0, -1), true);
|
|
759
|
+
const last = path.at(-1);
|
|
760
|
+
let range;
|
|
761
|
+
if (isMap(parent) && typeof last === "string") {
|
|
762
|
+
const pair = parent.items.find((item) => isScalar(item.key) && item.key.value === last);
|
|
763
|
+
range = isScalar(pair?.key) ? pair.key.range : void 0;
|
|
764
|
+
} else {
|
|
765
|
+
const target = isSeq(parent) && typeof last === "number" ? parent.items[last] : parent;
|
|
766
|
+
range = isNode(target) ? target.range : void 0;
|
|
767
|
+
}
|
|
768
|
+
const position = lineCounter.linePos(range?.[0] ?? 0);
|
|
769
|
+
return {
|
|
770
|
+
line: position.line,
|
|
771
|
+
column: position.col
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Parse YAML text. It throws `ConformanceError` naming `file` when the text is not valid YAML.
|
|
776
|
+
*/
|
|
777
|
+
function parseYaml(file, source) {
|
|
778
|
+
const lineCounter = new LineCounter();
|
|
779
|
+
const document = parseDocument(source, {
|
|
780
|
+
lineCounter,
|
|
781
|
+
prettyErrors: false
|
|
782
|
+
});
|
|
783
|
+
const [error] = document.errors;
|
|
784
|
+
if (error !== void 0) throw new ConformanceError(`${file}: not valid YAML: ${error.message}`, { cause: error });
|
|
785
|
+
return {
|
|
786
|
+
value: document.toJS(),
|
|
787
|
+
locate: (path) => locationAt(document, lineCounter, path)
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* A reader for one file, so the helpers can name the field they read when the file is not a workflow.
|
|
792
|
+
*/
|
|
793
|
+
var Reader = class {
|
|
794
|
+
file;
|
|
795
|
+
parsed;
|
|
796
|
+
constructor(file, parsed) {
|
|
797
|
+
this.file = file;
|
|
798
|
+
this.parsed = parsed;
|
|
799
|
+
}
|
|
800
|
+
fail(what) {
|
|
801
|
+
throw new ConformanceError(`${this.file}: ${what}`);
|
|
802
|
+
}
|
|
803
|
+
location(path) {
|
|
804
|
+
return this.parsed.locate(path);
|
|
805
|
+
}
|
|
806
|
+
record(value, what) {
|
|
807
|
+
if (value === void 0 || value === null) return {};
|
|
808
|
+
if (!isRecord(value)) return this.fail(`${what} must be a mapping`);
|
|
809
|
+
return value;
|
|
810
|
+
}
|
|
811
|
+
text(value, what) {
|
|
812
|
+
if (value === void 0 || value === null) return;
|
|
813
|
+
return scalarText(value) ?? this.fail(`${what} must be a string, number or boolean`);
|
|
814
|
+
}
|
|
815
|
+
texts(value, what) {
|
|
816
|
+
return Object.fromEntries(Object.entries(this.record(value, what)).map(([key, entry]) => [key, this.text(entry, `${what}.${key}`) ?? ""]));
|
|
817
|
+
}
|
|
818
|
+
list(value, what) {
|
|
819
|
+
if (value === void 0 || value === null) return [];
|
|
820
|
+
return (Array.isArray(value) ? value : [value]).map((item) => this.text(item, what) ?? this.fail(`${what} must be a string or a list of strings`));
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
const PERMISSION_ACCESS = /* @__PURE__ */ new Set([
|
|
824
|
+
"read",
|
|
825
|
+
"write",
|
|
826
|
+
"none"
|
|
827
|
+
]);
|
|
828
|
+
function isPermissionAccess(value) {
|
|
829
|
+
return PERMISSION_ACCESS.has(value);
|
|
830
|
+
}
|
|
831
|
+
function permissionsOf(reader, value, what) {
|
|
832
|
+
if (value === void 0 || value === null) return { kind: "default" };
|
|
833
|
+
if (value === "read-all") return {
|
|
834
|
+
kind: "all",
|
|
835
|
+
access: "read"
|
|
836
|
+
};
|
|
837
|
+
if (value === "write-all") return {
|
|
838
|
+
kind: "all",
|
|
839
|
+
access: "write"
|
|
840
|
+
};
|
|
841
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
842
|
+
for (const [scope, access] of Object.entries(reader.record(value, what))) {
|
|
843
|
+
const text = reader.text(access, `${what}.${scope}`);
|
|
844
|
+
if (text === void 0 || !isPermissionAccess(text)) return reader.fail(`${what}.${scope} must be read, write or none`);
|
|
845
|
+
scopes.set(scope, text);
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
kind: "scopes",
|
|
849
|
+
scopes
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
function runsOnOf(reader, value, what) {
|
|
853
|
+
const mapping = isRecord(value) ? value : void 0;
|
|
854
|
+
const entries = reader.list(mapping === void 0 ? value : mapping["labels"], what);
|
|
855
|
+
const group = mapping === void 0 ? void 0 : reader.text(mapping["group"], `${what}.group`);
|
|
856
|
+
return {
|
|
857
|
+
labels: entries.filter((entry) => !entry.includes("${{")),
|
|
858
|
+
expressions: entries.filter((entry) => entry.includes("${{")),
|
|
859
|
+
group
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
function stepOf(reader, value, jobPath, index) {
|
|
863
|
+
const path = [
|
|
864
|
+
...jobPath,
|
|
865
|
+
"steps",
|
|
866
|
+
index
|
|
867
|
+
];
|
|
868
|
+
const what = path.join(".");
|
|
869
|
+
const step = reader.record(value, what);
|
|
870
|
+
return {
|
|
871
|
+
index,
|
|
872
|
+
id: reader.text(step["id"], `${what}.id`),
|
|
873
|
+
name: reader.text(step["name"], `${what}.name`),
|
|
874
|
+
uses: reader.text(step["uses"], `${what}.uses`),
|
|
875
|
+
run: reader.text(step["run"], `${what}.run`),
|
|
876
|
+
with: reader.texts(step["with"], `${what}.with`),
|
|
877
|
+
env: reader.texts(step["env"], `${what}.env`),
|
|
878
|
+
condition: reader.text(step["if"], `${what}.if`),
|
|
879
|
+
location: reader.location(path)
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
function jobOf(reader, id, value) {
|
|
883
|
+
const path = ["jobs", id];
|
|
884
|
+
const what = path.join(".");
|
|
885
|
+
const job = reader.record(value, what);
|
|
886
|
+
const steps = job["steps"];
|
|
887
|
+
if (steps !== void 0 && !Array.isArray(steps)) return reader.fail(`${what}.steps must be a list`);
|
|
888
|
+
return {
|
|
889
|
+
id,
|
|
890
|
+
name: reader.text(job["name"], `${what}.name`),
|
|
891
|
+
needs: reader.list(job["needs"], `${what}.needs`),
|
|
892
|
+
condition: reader.text(job["if"], `${what}.if`),
|
|
893
|
+
permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
|
|
894
|
+
env: reader.texts(job["env"], `${what}.env`),
|
|
895
|
+
runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
|
|
896
|
+
timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
|
|
897
|
+
uses: reader.text(job["uses"], `${what}.uses`),
|
|
898
|
+
with: reader.texts(job["with"], `${what}.with`),
|
|
899
|
+
outputs: reader.texts(job["outputs"], `${what}.outputs`),
|
|
900
|
+
steps: (steps ?? []).map((step, index) => stepOf(reader, step, path, index)),
|
|
901
|
+
location: reader.location(path)
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
function triggerLocationsOf(reader, triggers, value) {
|
|
905
|
+
const events = [...triggers.keys()];
|
|
906
|
+
return new Map(events.map((event, index) => [event, reader.location(Array.isArray(value) ? ["on", index] : isRecord(value) ? ["on", event] : ["on"])]));
|
|
907
|
+
}
|
|
908
|
+
function triggersOf(reader, value) {
|
|
909
|
+
if (typeof value === "string") return /* @__PURE__ */ new Map([[value, void 0]]);
|
|
910
|
+
if (Array.isArray(value)) return new Map(reader.list(value, "on").map((event) => [event, void 0]));
|
|
911
|
+
return new Map(Object.entries(reader.record(value, "on")));
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Parse the text of a workflow file into the model the checks read. It throws `ConformanceError` when the file is not YAML or is not shaped like a workflow, since a check cannot judge what it cannot read.
|
|
915
|
+
*/
|
|
916
|
+
function parseWorkflow(file, source) {
|
|
917
|
+
const parsed = parseYaml(file, source);
|
|
918
|
+
const reader = new Reader(file, parsed);
|
|
919
|
+
const root = reader.record(parsed.value, "the workflow");
|
|
920
|
+
const triggers = triggersOf(reader, root["on"]);
|
|
921
|
+
const call = triggers.get("workflow_call");
|
|
922
|
+
const outputs = reader.record(reader.record(call, "on.workflow_call")["outputs"], "on.workflow_call.outputs");
|
|
923
|
+
return {
|
|
924
|
+
file,
|
|
925
|
+
name: reader.text(root["name"], "name"),
|
|
926
|
+
triggers,
|
|
927
|
+
triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
|
|
928
|
+
permissions: permissionsOf(reader, root["permissions"], "permissions"),
|
|
929
|
+
env: reader.texts(root["env"], "env"),
|
|
930
|
+
jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
|
|
931
|
+
callOutputs: Object.fromEntries(Object.entries(outputs).map(([name, output]) => [name, reader.text(reader.record(output, `on.workflow_call.outputs.${name}`)["value"], `on.workflow_call.outputs.${name}.value`) ?? ""]))
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* The permissions that apply to a job: its own when it declares any, else the workflow's. A job's declaration replaces the workflow's whole, it is not merged with it.
|
|
936
|
+
*/
|
|
937
|
+
function effectivePermissions(workflow, job) {
|
|
938
|
+
return job.permissions.kind === "default" ? workflow.permissions : job.permissions;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* The environment a step runs with: the workflow's, the job's and the step's, the later winning.
|
|
942
|
+
*/
|
|
943
|
+
function effectiveEnv(workflow, job, step) {
|
|
944
|
+
return {
|
|
945
|
+
...workflow.env,
|
|
946
|
+
...job.env,
|
|
947
|
+
...step?.env
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Every job `jobId` waits for, directly or through other jobs, not including itself. A `needs` that names no job of the workflow is followed no further.
|
|
952
|
+
*/
|
|
953
|
+
function transitiveNeeds(workflow, jobId) {
|
|
954
|
+
const byId = new Map(workflow.jobs.map((job) => [job.id, job]));
|
|
955
|
+
const reached = /* @__PURE__ */ new Set();
|
|
956
|
+
const pending = [...byId.get(jobId)?.needs ?? []];
|
|
957
|
+
for (let next = pending.pop(); next !== void 0; next = pending.pop()) if (!reached.has(next)) {
|
|
958
|
+
reached.add(next);
|
|
959
|
+
pending.push(...byId.get(next)?.needs ?? []);
|
|
960
|
+
}
|
|
961
|
+
reached.delete(jobId);
|
|
962
|
+
return reached;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Split a `uses` value into its parts.
|
|
966
|
+
*/
|
|
967
|
+
function parseUses(uses) {
|
|
968
|
+
if (uses.startsWith("./")) return {
|
|
969
|
+
kind: "local",
|
|
970
|
+
path: uses
|
|
971
|
+
};
|
|
972
|
+
if (uses.startsWith("docker://")) {
|
|
973
|
+
const reference = uses.slice(9);
|
|
974
|
+
const at = reference.indexOf("@");
|
|
975
|
+
return at === -1 ? {
|
|
976
|
+
kind: "docker",
|
|
977
|
+
image: reference,
|
|
978
|
+
digest: void 0
|
|
979
|
+
} : {
|
|
980
|
+
kind: "docker",
|
|
981
|
+
image: reference.slice(0, at),
|
|
982
|
+
digest: reference.slice(at + 1)
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
const at = uses.lastIndexOf("@");
|
|
986
|
+
const [owner = "", repository = "", ...path] = (at === -1 ? uses : uses.slice(0, at)).split("/");
|
|
987
|
+
return {
|
|
988
|
+
kind: "remote",
|
|
989
|
+
owner,
|
|
990
|
+
repository,
|
|
991
|
+
path: path.join("/"),
|
|
992
|
+
ref: at === -1 ? void 0 : uses.slice(at + 1)
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Whether `value` contains an expression, which is only known when the workflow runs.
|
|
997
|
+
*/
|
|
998
|
+
function hasExpression(value) {
|
|
999
|
+
return value.includes("${{");
|
|
1000
|
+
}
|
|
1001
|
+
//#endregion
|
|
1002
|
+
//#region src/workflows/load.ts
|
|
1003
|
+
/**
|
|
1004
|
+
* Where GitHub looks for workflows: only this directory, and only the files directly in it.
|
|
1005
|
+
*/
|
|
1006
|
+
const DEFAULT_WORKFLOWS = [".github/workflows/*.yml", ".github/workflows/*.yaml"];
|
|
1007
|
+
/**
|
|
1008
|
+
* Read and parse the selected workflow files, sorted by path.
|
|
1009
|
+
*/
|
|
1010
|
+
async function loadWorkflows(cwd, selection) {
|
|
1011
|
+
const files = await findFiles(cwd, [...selection.workflows ?? DEFAULT_WORKFLOWS, ...excludedFrom(selection.exclude)]);
|
|
1012
|
+
return Promise.all(files.map(async (file) => parseWorkflow(file, await readFile(resolve(cwd, file), "utf8"))));
|
|
1013
|
+
}
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region src/workflows/shared.ts
|
|
1016
|
+
/**
|
|
1017
|
+
* A violation about a place in a workflow file.
|
|
1018
|
+
*/
|
|
1019
|
+
function workflowViolation(workflow, code, message, location) {
|
|
1020
|
+
return {
|
|
1021
|
+
code,
|
|
1022
|
+
message,
|
|
1023
|
+
file: workflow.file,
|
|
1024
|
+
location
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* The commands of a `run` script as logical lines: a line ended by a backslash is joined to the next one, comment lines and blank lines are dropped.
|
|
1029
|
+
*/
|
|
1030
|
+
function commandLines(run) {
|
|
1031
|
+
const lines = [];
|
|
1032
|
+
let continued = "";
|
|
1033
|
+
for (const line of run.split("\n")) {
|
|
1034
|
+
const joined = `${continued}${line.trim()}`;
|
|
1035
|
+
if (joined.endsWith("\\")) continued = `${joined.slice(0, -1).trimEnd()} `;
|
|
1036
|
+
else {
|
|
1037
|
+
continued = "";
|
|
1038
|
+
if (joined !== "" && !joined.startsWith("#")) lines.push(joined);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (continued !== "") lines.push(continued.trim());
|
|
1042
|
+
return lines;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Variable assignments that prefix a command (`HUSKY=0 pnpm exec ...`).
|
|
1046
|
+
*/
|
|
1047
|
+
const LEADING_ASSIGNMENTS = /^(?:[A-Za-z_]\w*=\S*\s+)+/u;
|
|
1048
|
+
/**
|
|
1049
|
+
* The simple commands of a logical line, without the variable assignments that prefix them: it is split at `&&`, `||`, `;`, `|` and `&` that are outside quotes and outside `${{ }}` expressions, and that are not part of a redirection (`2>&1`, `&>file`), so text inside a quoted string (an `echo` of a command) is not mistaken for a command.
|
|
1050
|
+
*/
|
|
1051
|
+
function commandSegments(line) {
|
|
1052
|
+
const segments = [];
|
|
1053
|
+
let current = "";
|
|
1054
|
+
let quote;
|
|
1055
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
1056
|
+
const rest = line.slice(index);
|
|
1057
|
+
const character = rest.charAt(0);
|
|
1058
|
+
if (quote !== void 0) {
|
|
1059
|
+
current += character;
|
|
1060
|
+
quote = character === quote ? void 0 : quote;
|
|
1061
|
+
} else if (rest.startsWith("${{")) {
|
|
1062
|
+
const end = rest.indexOf("}}");
|
|
1063
|
+
const expression = end === -1 ? rest : rest.slice(0, end + 2);
|
|
1064
|
+
current += expression;
|
|
1065
|
+
index += expression.length - 1;
|
|
1066
|
+
} else if (character === "\"" || character === "'") {
|
|
1067
|
+
current += character;
|
|
1068
|
+
quote = character;
|
|
1069
|
+
} else if (character === "&" && (current.endsWith(">") || current.endsWith("<") || rest.startsWith("&>"))) current += character;
|
|
1070
|
+
else if (character === "&" || character === "|" || character === ";") {
|
|
1071
|
+
segments.push(current);
|
|
1072
|
+
current = "";
|
|
1073
|
+
} else current += character;
|
|
1074
|
+
}
|
|
1075
|
+
segments.push(current);
|
|
1076
|
+
return segments.map((segment) => segment.trim().replace(LEADING_ASSIGNMENTS, "")).filter((segment) => segment !== "");
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* The simple commands of every line of a `run` script.
|
|
1080
|
+
*/
|
|
1081
|
+
function scriptCommands(run) {
|
|
1082
|
+
return commandLines(run ?? "").flatMap(commandSegments);
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Whether a step uses the action `owner/repository`, at any path or ref.
|
|
1086
|
+
*/
|
|
1087
|
+
function stepUses(step, action) {
|
|
1088
|
+
const target = step.uses?.split("@")[0]?.toLowerCase();
|
|
1089
|
+
return target === action.toLowerCase() || target?.startsWith(`${action.toLowerCase()}/`) === true;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The jobs among `ids` that exist in the workflow, in the workflow's order.
|
|
1093
|
+
*/
|
|
1094
|
+
function jobsNamed(workflow, ids) {
|
|
1095
|
+
return workflow.jobs.filter((job) => ids.includes(job.id));
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Whether an `if` expression calls `always()`.
|
|
1099
|
+
*/
|
|
1100
|
+
function callsAlways(condition) {
|
|
1101
|
+
return condition !== void 0 && /\balways\(\)/u.test(condition);
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* The default ids of junction jobs, shared by the checks that look for one.
|
|
1105
|
+
*/
|
|
1106
|
+
const DEFAULT_JUNCTION_JOBS = ["required-checks"];
|
|
1107
|
+
//#endregion
|
|
1108
|
+
//#region src/checks/settings-required-checks.ts
|
|
1109
|
+
/**
|
|
1110
|
+
* The branch requires the check of every junction job of the workflows, and requires branches to be up to date before merging (strict mode). The check name of a job is its `name`, else its id.
|
|
1111
|
+
*
|
|
1112
|
+
* The junction job is the one that reports for the whole workflow, so requiring it requires everything it needs, including jobs a path filter may skip. Codes: `no-required-checks` (no `required_status_checks` rule applies), `required-check-missing` (a junction job's check is not among the required ones), `not-strict` (no rule sets the strict policy) and `no-junction-job` (no workflow has a job with an id from `junctionJobs`).
|
|
1113
|
+
*
|
|
1114
|
+
* Limits: it reads the rules of rulesets that apply to the branch; classic branch protection is not read, so a repository that uses it is reported as having no required checks. A junction job in a reusable workflow, or a name that is an expression, is not resolved.
|
|
1115
|
+
*/
|
|
1116
|
+
const settingsRequiredChecks = async ({ cwd, options, github }) => {
|
|
1117
|
+
const slug = await repositoryOf(cwd, options);
|
|
1118
|
+
const file = repositoryFile(slug);
|
|
1119
|
+
const rules = await github.branchRules(slug, await branchOf(github, slug, options));
|
|
1120
|
+
const junctions = (await loadWorkflows(cwd, options)).flatMap((workflow) => jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS).map((job) => ({
|
|
1121
|
+
workflow,
|
|
1122
|
+
name: job.name ?? job.id
|
|
1123
|
+
})));
|
|
1124
|
+
const violations = [];
|
|
1125
|
+
if (junctions.length === 0) violations.push({
|
|
1126
|
+
code: "settings-required-checks/no-junction-job",
|
|
1127
|
+
message: "no workflow has a junction job, so there is no check that reports for the whole workflow to require",
|
|
1128
|
+
file
|
|
1129
|
+
});
|
|
1130
|
+
if (rules.requiredStatusChecks.length === 0) {
|
|
1131
|
+
violations.push({
|
|
1132
|
+
code: "settings-required-checks/no-required-checks",
|
|
1133
|
+
message: `no rule requires status checks on the branch of ${file}`,
|
|
1134
|
+
file
|
|
1135
|
+
});
|
|
1136
|
+
return violations;
|
|
1137
|
+
}
|
|
1138
|
+
const required = new Set(rules.requiredStatusChecks.flatMap((rule) => rule.contexts));
|
|
1139
|
+
for (const { workflow, name } of junctions.filter((junction) => !required.has(junction.name))) violations.push({
|
|
1140
|
+
code: "settings-required-checks/required-check-missing",
|
|
1141
|
+
message: `${file} does not require the check '${name}' of ${workflow.file}`,
|
|
1142
|
+
file
|
|
1143
|
+
});
|
|
1144
|
+
if (!rules.requiredStatusChecks.some((rule) => rule.strict)) violations.push({
|
|
1145
|
+
code: "settings-required-checks/not-strict",
|
|
1146
|
+
message: `${file} does not require branches to be up to date before merging`,
|
|
1147
|
+
file
|
|
1148
|
+
});
|
|
1149
|
+
return violations;
|
|
1150
|
+
};
|
|
1151
|
+
//#endregion
|
|
1152
|
+
//#region src/checks/settings-review-thread-resolution.ts
|
|
1153
|
+
/**
|
|
1154
|
+
* A pull request cannot merge while a review conversation is unresolved: a `pull_request` rule that applies to the branch sets `required_review_thread_resolution`.
|
|
1155
|
+
*
|
|
1156
|
+
* Limits: it reads the rules of rulesets; classic branch protection is not read.
|
|
1157
|
+
*/
|
|
1158
|
+
const settingsReviewThreadResolution = async ({ cwd, options, github }) => {
|
|
1159
|
+
const slug = await repositoryOf(cwd, options);
|
|
1160
|
+
const rules = await github.branchRules(slug, await branchOf(github, slug, options));
|
|
1161
|
+
const file = repositoryFile(slug);
|
|
1162
|
+
return rules.pullRequests.some((rule) => rule.requiredReviewThreadResolution) ? [] : [{
|
|
1163
|
+
code: "settings-review-thread-resolution/not-required",
|
|
1164
|
+
message: `no rule requires review conversations to be resolved before merging on the branch of ${file}`,
|
|
1165
|
+
file
|
|
1166
|
+
}];
|
|
1167
|
+
};
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/checks/workflow-action-pinning.ts
|
|
1170
|
+
const FULL_COMMIT_SHA = /^[0-9a-f]{40}$/u;
|
|
1171
|
+
const DOCKER_DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
|
1172
|
+
/**
|
|
1173
|
+
* Whether `repository` (`owner/repository`) matches an `allow` entry, which is `owner/repository` or `owner/*`.
|
|
1174
|
+
*/
|
|
1175
|
+
function allowed(owner, repository, allow) {
|
|
1176
|
+
return allow.some((entry) => entry.toLowerCase() === `${owner}/${repository}`.toLowerCase() || entry.toLowerCase() === `${owner}/*`.toLowerCase());
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Why a reference breaks the policy, with the code of the violation, or `undefined` when it follows it.
|
|
1180
|
+
*/
|
|
1181
|
+
function problem(reference, options) {
|
|
1182
|
+
if (reference.kind === "local") return;
|
|
1183
|
+
if (reference.kind === "docker") return reference.digest !== void 0 && DOCKER_DIGEST.test(reference.digest) ? void 0 : {
|
|
1184
|
+
code: "docker-not-pinned",
|
|
1185
|
+
reason: "a Docker image must be pinned by digest (@sha256:...), because a tag can be moved"
|
|
1186
|
+
};
|
|
1187
|
+
const { owner, repository, ref } = reference;
|
|
1188
|
+
if (allowed(owner, repository, options.allow ?? [])) return;
|
|
1189
|
+
if (ref === void 0 || ref === "") return {
|
|
1190
|
+
code: "missing-ref",
|
|
1191
|
+
reason: "it names no ref"
|
|
1192
|
+
};
|
|
1193
|
+
return ((options.organisations ?? []).some((organisation) => organisation.toLowerCase() === owner.toLowerCase()) ? options.sameOrganisation ?? "ref" : options.thirdParty ?? "sha") === "sha" && !FULL_COMMIT_SHA.test(ref) ? {
|
|
1194
|
+
code: "not-pinned",
|
|
1195
|
+
reason: `ref '${ref}' is not a full commit SHA, and a tag or branch can be moved to different code`
|
|
1196
|
+
} : void 0;
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Every `uses` of a step or job follows the pinning policy. By default an action or reusable workflow from outside the organisations in the options must be pinned by full commit SHA (a Docker image by digest), and one owned by an organisation listed in `organisations` may use any ref; `thirdParty` and `sameOrganisation` set either to `sha` or `ref`, and `allow` exempts repositories. Local actions and workflows (`./...`) are not judged.
|
|
1200
|
+
*
|
|
1201
|
+
* The check encodes whichever policy the options state and is never silent about it: with nothing configured every remote reference must be a SHA. Limits: it checks the form of the ref, not that the SHA belongs to the repository or to a release; a SHA written with a trailing version comment is read as the SHA; composite actions are not read.
|
|
1202
|
+
*/
|
|
1203
|
+
const workflowActionPinning = async ({ cwd, options }) => {
|
|
1204
|
+
const violations = [];
|
|
1205
|
+
for (const workflow of await loadWorkflows(cwd, options)) {
|
|
1206
|
+
const uses = [...workflow.jobs.flatMap((job) => job.uses === void 0 ? [] : [{
|
|
1207
|
+
value: job.uses,
|
|
1208
|
+
location: job.location
|
|
1209
|
+
}]), ...workflow.jobs.flatMap((job) => job.steps.flatMap((step) => step.uses === void 0 ? [] : [{
|
|
1210
|
+
value: step.uses,
|
|
1211
|
+
location: step.location
|
|
1212
|
+
}]))].sort((left, right) => left.location.line - right.location.line || left.location.column - right.location.column);
|
|
1213
|
+
for (const { value, location } of uses) {
|
|
1214
|
+
const found = problem(parseUses(value), options);
|
|
1215
|
+
if (found !== void 0) violations.push(workflowViolation(workflow, `workflow-action-pinning/${found.code}`, `${value}: ${found.reason}`, location));
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
return violations;
|
|
1219
|
+
};
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region src/checks/workflow-credentials.ts
|
|
1222
|
+
/**
|
|
1223
|
+
* The actions that sign an attestation, which needs an OIDC identity and the right to store the attestation.
|
|
1224
|
+
*/
|
|
1225
|
+
const ATTESTATION_ACTIONS = [
|
|
1226
|
+
"actions/attest",
|
|
1227
|
+
"actions/attest-build-provenance",
|
|
1228
|
+
"actions/attest-sbom"
|
|
1229
|
+
];
|
|
1230
|
+
/**
|
|
1231
|
+
* The scopes an attestation step needs written.
|
|
1232
|
+
*/
|
|
1233
|
+
const ATTESTATION_SCOPES = ["id-token", "attestations"];
|
|
1234
|
+
/**
|
|
1235
|
+
* The options a package manager takes before its subcommand: flags such as `-r`, and the options that select a workspace package or directory, with their value after a space or an equals sign.
|
|
1236
|
+
*/
|
|
1237
|
+
const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-w|--dir|-C)(?:\s+|=)\S+|--?[\w-]+))*`;
|
|
1238
|
+
/**
|
|
1239
|
+
* Commands that publish a package to a registry.
|
|
1240
|
+
*/
|
|
1241
|
+
const PUBLISH_COMMAND = new RegExp(String.raw`^(?:(?:npm|pnpm|yarn)${PACKAGE_MANAGER_OPTIONS}(?:\s+npm)?\s+publish|(?:(?:pnpm|yarn)(?:\s+exec)?\s+|npx\s+)?(?:semantic-release|changeset\s+publish|lerna\s+publish))(?:\s|$)`, "u");
|
|
1242
|
+
/**
|
|
1243
|
+
* The variables a token for the registry is passed in.
|
|
1244
|
+
*/
|
|
1245
|
+
const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
1246
|
+
const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
|
|
1247
|
+
/**
|
|
1248
|
+
* Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
|
|
1249
|
+
*/
|
|
1250
|
+
async function requestsProvenance(cwd) {
|
|
1251
|
+
const file = resolve(cwd, "package.json");
|
|
1252
|
+
if (!existsSync(file)) return false;
|
|
1253
|
+
const manifest = JSON.parse(await readFile(file, "utf8"));
|
|
1254
|
+
const config = isRecord(manifest) ? manifest["publishConfig"] : void 0;
|
|
1255
|
+
return isRecord(config) && config["provenance"] === true;
|
|
1256
|
+
}
|
|
1257
|
+
function attestationViolations(workflow, job) {
|
|
1258
|
+
const permissions = effectivePermissions(workflow, job);
|
|
1259
|
+
return job.steps.filter((step) => ATTESTATION_ACTIONS.some((action) => stepUses(step, action))).flatMap((step) => {
|
|
1260
|
+
const missing = ATTESTATION_SCOPES.filter((scope) => accessOf(permissions, scope) !== "write").map((scope) => `${scope}: write`).join(" and ");
|
|
1261
|
+
const problem = permissions.kind === "default" ? `declares no permissions; declare ${missing}` : `does not grant ${missing}`;
|
|
1262
|
+
return missing === "" ? [] : [workflowViolation(workflow, "workflow-credentials/attestation-permissions", `job '${job.id}' creates an attestation but ${problem}`, step.location)];
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Whether the job passes a registry token to a step, which makes its publish not tokenless: a token variable with a value other than the empty string, anywhere in the environment.
|
|
1267
|
+
*/
|
|
1268
|
+
function passesToken(workflow, job) {
|
|
1269
|
+
return [void 0, ...job.steps].some((step) => TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
|
|
1270
|
+
}
|
|
1271
|
+
function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
1272
|
+
if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
|
|
1273
|
+
return job.steps.flatMap((step) => {
|
|
1274
|
+
const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
|
|
1275
|
+
if (publishes.length === 0) return [];
|
|
1276
|
+
const env = effectiveEnv(workflow, job, step);
|
|
1277
|
+
const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
|
|
1278
|
+
const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
|
|
1279
|
+
return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${TOKEN_VARIABLES.join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set them to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Credentials are granted to the steps that need them, and a publish with no token cannot quietly use one.
|
|
1284
|
+
*
|
|
1285
|
+
* `workflow-credentials/attestation-permissions`: a step of `actions/attest`, `actions/attest-build-provenance` or `actions/attest-sbom` needs `id-token: write` and `attestations: write` in the job's effective permissions. Permissions a job declares replace the workflow's; permissions declared nowhere are the repository's default, which the file cannot show and which never includes `id-token`, so they are reported.
|
|
1286
|
+
*
|
|
1287
|
+
* `workflow-credentials/tokenless-publish-unprotected`: a job with `id-token: write` that publishes (`npm`, `pnpm` or `yarn npm publish`, with options such as `-r` or `--filter <package>` before `publish`, `semantic-release`, `changeset publish`, `lerna publish`, bare or run through `pnpm`, `pnpm exec`, `yarn` or `npx`) and passes no token must blank `NODE_AUTH_TOKEN` and `NPM_TOKEN` (set them to `''` at step, job or workflow level) or request provenance (`--provenance`, `NPM_CONFIG_PROVENANCE: true`, or `publishConfig.provenance: true` in the `package.json` of the working directory). The npm CLI replaces any configured token with the one from the OIDC exchange, but when the exchange fails it falls back to whatever token the environment holds, silently. Only blanking the variables prevents that fallback. Provenance does not: npm signs it with the CI's own OIDC identity (through Sigstore), separately from the registry token, so a fallback publish still goes ahead with the inherited token and carries an attestation of where it was built. Requesting provenance is accepted because it makes such a publish traceable, not because it makes it safe.
|
|
1288
|
+
*
|
|
1289
|
+
* Limits: `registry-url` is not reported, because it does not stop the exchange (see the README); a job that calls a reusable workflow is not read; a publish inside a script file or composite action is not seen; provenance set in a `package.json` other than the one in the working directory is not read; a job is tokenless only when no token variable is non-empty anywhere in the job, so a publish that uses a secret is left alone.
|
|
1290
|
+
*/
|
|
1291
|
+
const workflowCredentials = async ({ cwd, options }) => {
|
|
1292
|
+
const provenanceInManifest = await requestsProvenance(cwd);
|
|
1293
|
+
return (await loadWorkflows(cwd, options)).flatMap((workflow) => workflow.jobs.flatMap((job) => [...attestationViolations(workflow, job), ...tokenlessPublishViolations(workflow, job, provenanceInManifest)]));
|
|
1294
|
+
};
|
|
1295
|
+
//#endregion
|
|
1296
|
+
//#region src/checks/workflow-job-ordering.ts
|
|
1297
|
+
const DEFAULT_RELEASE_JOBS = ["release"];
|
|
1298
|
+
const DEFAULT_DEPLOY_JOBS = ["deploy"];
|
|
1299
|
+
const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
|
|
1300
|
+
const DEFAULT_BRANCH = "main";
|
|
1301
|
+
/**
|
|
1302
|
+
* The expression that always names the repository's default branch.
|
|
1303
|
+
*/
|
|
1304
|
+
const DEFAULT_BRANCH_EXPRESSION$1 = /^\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}$/u;
|
|
1305
|
+
/**
|
|
1306
|
+
* The text a junction job decides on: its `if`, and the conditions and scripts of its steps.
|
|
1307
|
+
*/
|
|
1308
|
+
function decisionText(job) {
|
|
1309
|
+
return [job.condition, ...job.steps.flatMap((step) => [
|
|
1310
|
+
step.condition,
|
|
1311
|
+
step.run,
|
|
1312
|
+
...Object.values(step.env),
|
|
1313
|
+
...Object.values(step.with)
|
|
1314
|
+
])].filter((text) => text !== void 0).join("\n");
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Where a script gets the results of the jobs it needs from: `needs.*.result`, one job's `needs.<job>.result` or the whole of `needs`.
|
|
1318
|
+
*/
|
|
1319
|
+
const RESULT_SOURCE = /needs\.(?:\*|[\w-]+)\.result|toJSON\(\s*needs\s*\)/u;
|
|
1320
|
+
const FAILURE_WORD = /['"]failure['"]/u;
|
|
1321
|
+
const CANCELLED_WORD = /['"]cancelled['"]/u;
|
|
1322
|
+
const SKIPPED_OR_NOT_SUCCESS = /needs\.(?:\*|[\w-]+)\.result[^\n]*(?:['"]skipped['"]|!==?\s*['"]success['"])|!\s*contains\(\s*needs\.\*\.result\s*,\s*['"]success['"]/u;
|
|
1323
|
+
/**
|
|
1324
|
+
* An `if` that compares the ref or the event name.
|
|
1325
|
+
*/
|
|
1326
|
+
const COMPARES_REF_OR_EVENT = /github\.(?:ref|ref_name|event_name)\s*==/u;
|
|
1327
|
+
/**
|
|
1328
|
+
* An expression written inside the `${{ }}` that marks it as one.
|
|
1329
|
+
*/
|
|
1330
|
+
const EXPRESSION_WRAPPER = /^\$\{\{\s*([\s\S]*?)\s*\}\}$/u;
|
|
1331
|
+
/**
|
|
1332
|
+
* The operands of `operator` at the top level of an expression: not inside parentheses or quotes.
|
|
1333
|
+
*/
|
|
1334
|
+
function operands(expression, operator) {
|
|
1335
|
+
const parts = [];
|
|
1336
|
+
let depth = 0;
|
|
1337
|
+
let quote;
|
|
1338
|
+
let start = 0;
|
|
1339
|
+
for (let index = 0; index < expression.length; index += 1) {
|
|
1340
|
+
const character = expression.charAt(index);
|
|
1341
|
+
if (quote !== void 0) quote = character === quote ? void 0 : quote;
|
|
1342
|
+
else if (character === "'" || character === "\"") quote = character;
|
|
1343
|
+
else if (character === "(") depth += 1;
|
|
1344
|
+
else if (character === ")") depth -= 1;
|
|
1345
|
+
else if (depth === 0 && expression.startsWith(operator, index)) {
|
|
1346
|
+
parts.push(expression.slice(start, index));
|
|
1347
|
+
index += operator.length - 1;
|
|
1348
|
+
start = index + 1;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
parts.push(expression.slice(start));
|
|
1352
|
+
return parts.map((part) => part.trim());
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Whether the first parenthesis of an expression is closed by its last character, so the parentheses enclose all of it, unlike `(a) && (b)`.
|
|
1356
|
+
*/
|
|
1357
|
+
function enclosedByOneGroup(expression) {
|
|
1358
|
+
let depth = 0;
|
|
1359
|
+
for (let index = 0; index < expression.length; index += 1) {
|
|
1360
|
+
depth += expression.charAt(index) === "(" ? 1 : expression.charAt(index) === ")" ? -1 : 0;
|
|
1361
|
+
if (depth === 0) return index === expression.length - 1;
|
|
1362
|
+
}
|
|
1363
|
+
return false;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* The expression without the `${{ }}` that wraps it, or the parentheses that enclose all of it.
|
|
1367
|
+
*/
|
|
1368
|
+
function unwrapped(expression) {
|
|
1369
|
+
const trimmed = expression.trim();
|
|
1370
|
+
const inner = EXPRESSION_WRAPPER.exec(trimmed)?.[1] ?? trimmed;
|
|
1371
|
+
return enclosedByOneGroup(inner) ? unwrapped(inner.slice(1, -1)) : inner;
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Whether an expression can only hold in runs other than those of a pull request and of a merge group, which are the runs the required check decides on: an operand of an `&&` that does so is enough, every operand of an `||` must, and a comparison of the ref or the event name does so when it names neither `pull_request` nor `merge_group`.
|
|
1375
|
+
*/
|
|
1376
|
+
function keepsOutOfPullRequests(expression) {
|
|
1377
|
+
if (expression === void 0) return false;
|
|
1378
|
+
const text = unwrapped(expression);
|
|
1379
|
+
const alternatives = operands(text, "||");
|
|
1380
|
+
if (alternatives.length > 1) return alternatives.every(keepsOutOfPullRequests);
|
|
1381
|
+
const conjuncts = operands(text, "&&");
|
|
1382
|
+
if (conjuncts.length > 1) return conjuncts.some(keepsOutOfPullRequests);
|
|
1383
|
+
return COMPARES_REF_OR_EVENT.test(text) && !text.includes("pull_request") && !text.includes("merge_group");
|
|
1384
|
+
}
|
|
1385
|
+
const OUTCOME_ACTION = "re-actors/alls-green";
|
|
1386
|
+
function releaseAndDocsOrdering(workflow, options) {
|
|
1387
|
+
const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
|
|
1388
|
+
const deploys = jobsNamed(workflow, options.deployJobs ?? DEFAULT_DEPLOY_JOBS);
|
|
1389
|
+
const docs = jobsNamed(workflow, options.docsDeployJobs ?? DEFAULT_DOCS_DEPLOY_JOBS);
|
|
1390
|
+
const violations = [];
|
|
1391
|
+
for (const release of releases) {
|
|
1392
|
+
const waitsFor = transitiveNeeds(workflow, release.id);
|
|
1393
|
+
for (const deploy of deploys.filter((candidate) => candidate.id !== release.id && !waitsFor.has(candidate.id))) violations.push(workflowViolation(workflow, "workflow-job-ordering/release-before-deploy", `release job '${release.id}' does not need deploy job '${deploy.id}', so a release can be published after a failed deploy`, release.location));
|
|
1394
|
+
}
|
|
1395
|
+
for (const doc of docs) {
|
|
1396
|
+
const waitsFor = transitiveNeeds(workflow, doc.id);
|
|
1397
|
+
for (const release of releases.filter((candidate) => candidate.id !== doc.id && !waitsFor.has(candidate.id))) violations.push(workflowViolation(workflow, "workflow-job-ordering/docs-deploy-before-release", `documentation job '${doc.id}' does not need release job '${release.id}', so it can publish documentation for a release that did not happen`, doc.location));
|
|
1398
|
+
const branch = options.defaultBranch ?? DEFAULT_BRANCH;
|
|
1399
|
+
for (const checkout of doc.steps.filter((step) => stepUses(step, "actions/checkout"))) {
|
|
1400
|
+
const ref = checkout.with["ref"]?.trim();
|
|
1401
|
+
if (!(ref !== void 0 && (DEFAULT_BRANCH_EXPRESSION$1.test(ref) || ref === branch || ref === `refs/heads/${branch}`))) violations.push(workflowViolation(workflow, "workflow-job-ordering/docs-deploy-stale-checkout", `documentation job '${doc.id}' checks out ${ref === void 0 ? "the triggering commit" : `'${ref}'`}, which predates the release commit; check out the default branch afresh with ref: \${{ github.event.repository.default_branch }}`, checkout.location));
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
return violations;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* The jobs a pull request's required check need not wait for: those the options exempt, those whose `if` keeps them to events other than pull requests and merge groups, and those that need such a job and so are skipped with it.
|
|
1408
|
+
*/
|
|
1409
|
+
function exemptFromJunction(workflow, junctionExempt) {
|
|
1410
|
+
const direct = /* @__PURE__ */ new Set([...junctionExempt, ...workflow.jobs.filter((job) => keepsOutOfPullRequests(job.condition)).map((job) => job.id)]);
|
|
1411
|
+
return new Set(workflow.jobs.filter((job) => direct.has(job.id) || [...transitiveNeeds(workflow, job.id)].some((needed) => direct.has(needed))).map((job) => job.id));
|
|
1412
|
+
}
|
|
1413
|
+
function junctionOrdering(workflow, options) {
|
|
1414
|
+
const violations = [];
|
|
1415
|
+
const exempt = exemptFromJunction(workflow, options.junctionExempt ?? []);
|
|
1416
|
+
for (const junction of jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS)) {
|
|
1417
|
+
if (!callsAlways(junction.condition)) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-not-always", `junction job '${junction.id}' has no if: always(), so it is skipped, and its required check never reports, when a job it needs fails`, junction.location));
|
|
1418
|
+
const missing = workflow.jobs.filter((job) => job.id !== junction.id && !exempt.has(job.id) && !junction.needs.includes(job.id) && !transitiveNeeds(workflow, job.id).has(junction.id));
|
|
1419
|
+
for (const job of missing) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-missing-need", `junction job '${junction.id}' does not list job '${job.id}' in needs, so its result is not part of the required check`, junction.location));
|
|
1420
|
+
const text = decisionText(junction);
|
|
1421
|
+
const reportsOutcome = junction.steps.some((step) => stepUses(step, OUTCOME_ACTION));
|
|
1422
|
+
if (SKIPPED_OR_NOT_SUCCESS.test(text)) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-fails-on-skipped", `junction job '${junction.id}' fails when a needed job is skipped, but a skipped job is how a path filter or an if keeps a job out; fail only on 'failure' and 'cancelled'`, junction.location));
|
|
1423
|
+
if (!reportsOutcome && !(RESULT_SOURCE.test(text) && FAILURE_WORD.test(text) && CANCELLED_WORD.test(text))) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-no-failure-condition", `junction job '${junction.id}' does not test the results of its needs for both 'failure' and 'cancelled', so it can pass when a job it needs did not succeed`, junction.location));
|
|
1424
|
+
}
|
|
1425
|
+
return violations;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* The jobs that release, deploy and report a required check are ordered so that a failure stops what follows: a release job needs the deploy job, a documentation deploy job needs the release job and checks out the default branch afresh, and one junction job with `if: always()` needs every other job and fails only on `failure` or `cancelled`.
|
|
1429
|
+
*
|
|
1430
|
+
* Jobs are recognised by id (see the options), and a role with no job in a workflow is not judged there. Ordering is judged through the transitive `needs` graph; the junction job must list every job directly, because `needs.*.result` only covers direct needs, except jobs that never run for a pull request or a merge group (an `if` on `github.ref` or `github.event_name` that mentions neither `pull_request` nor `merge_group`, where an `&&` needs one such operand and an `||` needs every operand to be), jobs that need such a job or the junction job itself, and the ids in `junctionExempt`. What the junction job decides on is read as text: it must read the results of its needs (`needs.*.result`, `needs.<job>.result` or `toJSON(needs)`) and name both `'failure'` and `'cancelled'`, or use the `re-actors/alls-green` action; a result read in a way the text does not show is reported as incomplete.
|
|
1431
|
+
*/
|
|
1432
|
+
const workflowJobOrdering = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => [...releaseAndDocsOrdering(workflow, options), ...junctionOrdering(workflow, options)]);
|
|
1433
|
+
//#endregion
|
|
1434
|
+
//#region src/checks/workflow-merge-group.ts
|
|
1435
|
+
/**
|
|
1436
|
+
* A workflow that checks pull requests also runs for the merge queue: GitHub starts no run of it for a queued pull request unless the workflow has a `merge_group` trigger, and a required check that never reports blocks the queue.
|
|
1437
|
+
*
|
|
1438
|
+
* A workflow is taken to produce required checks when it is triggered by `pull_request`; `pull_request_target` is left out, since it runs trusted code against untrusted input and is not a place for required checks. The file cannot show which checks are required, so a `pull_request` workflow whose checks are not required is reported too: narrow the check with `workflows` and `exclude`. A `merge_group` trigger on a workflow with no `pull_request` trigger is not judged. Limits: a `merge_group` trigger takes only `branches` filters (`paths` is a filter of `push`, `pull_request` and `pull_request_target`), so a workflow path-filtered for pull requests still runs in the queue once it has the trigger; the `branches` filter of a `merge_group` trigger is not read.
|
|
1439
|
+
*/
|
|
1440
|
+
const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => {
|
|
1441
|
+
const location = workflow.triggerLocations.get("pull_request");
|
|
1442
|
+
return location === void 0 || workflow.triggers.has("merge_group") ? [] : [workflowViolation(workflow, "workflow-merge-group/missing-trigger", "the workflow runs for pull requests but has no merge_group trigger, so its checks do not report for a pull request in the merge queue", location)];
|
|
1443
|
+
});
|
|
1444
|
+
//#endregion
|
|
1445
|
+
//#region src/checks/workflow-repository-dispatch.ts
|
|
1446
|
+
const DEFAULT_BRANCHES = ["main", "master"];
|
|
1447
|
+
const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
|
|
1448
|
+
const DEFAULT_TOKEN = /\b(?:secrets\.GITHUB_TOKEN|github\.token)\b/u;
|
|
1449
|
+
/**
|
|
1450
|
+
* The start of a `git push` command: `git`, the global options that take a value (`-C <directory>`, `-c <name=value>`) or are written `--name=value`, then `push`.
|
|
1451
|
+
*/
|
|
1452
|
+
const PUSH = /^git(?:\s+(?:-[Cc]\s+\S+|--[\w-]+=\S+))*\s+push\b/u;
|
|
1453
|
+
const BRANCH_CREATION = /^git\s+(?:checkout\s+-[bB]|switch\s+-[cC])\b/u;
|
|
1454
|
+
const AUTO_MERGE = /^gh\s+pr\s+merge\b.*\s--auto\b/u;
|
|
1455
|
+
const PULL_REQUEST_CREATION = /^gh\s+pr\s+create\b|^gh\s+api\b.*\brepos\/[^\s]+\/pulls\b/u;
|
|
1456
|
+
const DISPATCH_TARGETS = [/^(?:gh\s+api|curl)\b.*\brepos\/([^\s/]+\/[^\s/]+)\/dispatches\b/u, /^gh\s+workflow\s+run\b.*\s(?:-R|--repo)[\s=]+(\S+)/u];
|
|
1457
|
+
const AUTO_COMMIT_ACTION = "stefanzweifel/git-auto-commit-action";
|
|
1458
|
+
const PULL_REQUEST_ACTION = "peter-evans/create-pull-request";
|
|
1459
|
+
const DISPATCH_ACTION = "peter-evans/repository-dispatch";
|
|
1460
|
+
const CHECKOUT_ACTION = "actions/checkout";
|
|
1461
|
+
function isDefaultBranch(branch, branches) {
|
|
1462
|
+
const name = branch.replace(/^refs\/heads\//u, "");
|
|
1463
|
+
return branches.includes(name) || DEFAULT_BRANCH_EXPRESSION.test(name);
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* The `git push` options that take their value as the next word, which would otherwise be read as the remote.
|
|
1467
|
+
*/
|
|
1468
|
+
const PUSH_OPTIONS_WITH_VALUE = /* @__PURE__ */ new Set([
|
|
1469
|
+
"-o",
|
|
1470
|
+
"--push-option",
|
|
1471
|
+
"--repo",
|
|
1472
|
+
"--receive-pack",
|
|
1473
|
+
"--exec"
|
|
1474
|
+
]);
|
|
1475
|
+
/**
|
|
1476
|
+
* A URL with credentials in it (an expression such as `${{ secrets.TOKEN }}` may be part of them), which authenticates a push itself instead of using what the checkout left in git config.
|
|
1477
|
+
*/
|
|
1478
|
+
const URL_WITH_CREDENTIALS = /[a-z][a-z0-9+.-]*:\/\/(?:[^\s/@{]|\$\{\{[^}]*\}\})*@/iu;
|
|
1479
|
+
/**
|
|
1480
|
+
* The words of a `git push` command line after `push`, without the quotes that wrap a word.
|
|
1481
|
+
*/
|
|
1482
|
+
function pushWords(line) {
|
|
1483
|
+
return line.replace(PUSH, "").split(/\s+/u).map((word) => word.replace(/^(["'])(.*)\1$/u, "$2")).filter((word) => word !== "");
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* The refspecs of a `git push` command line, after the remote, the options and the values of the options that take one.
|
|
1487
|
+
*/
|
|
1488
|
+
function pushRefspecs(line) {
|
|
1489
|
+
const positional = [];
|
|
1490
|
+
const words = pushWords(line)[Symbol.iterator]();
|
|
1491
|
+
for (const word of words) if (PUSH_OPTIONS_WITH_VALUE.has(word)) words.next();
|
|
1492
|
+
else if (!word.startsWith("-")) positional.push(word);
|
|
1493
|
+
return positional.slice(1);
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Whether a `git push` command line pushes to the default branch. With a refspec it is the destination (a leading `+` only forces the update, and a bare `HEAD` is the checked-out branch); without one it is the branch the handler has checked out, which is the default branch unless the job created another or checked one out by `ref`.
|
|
1497
|
+
*/
|
|
1498
|
+
function pushesToDefault(line, onOtherBranch, branches) {
|
|
1499
|
+
const refspecs = pushRefspecs(line);
|
|
1500
|
+
if (refspecs.length === 0) return !onOtherBranch;
|
|
1501
|
+
return refspecs.some((refspec) => {
|
|
1502
|
+
const destination = refspec.replace(/^\+/u, "");
|
|
1503
|
+
const named = destination.includes(":") ? destination.slice(destination.lastIndexOf(":") + 1) : destination;
|
|
1504
|
+
return named === "HEAD" ? !onOtherBranch : isDefaultBranch(named, branches);
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Whether a `git push` command line authenticates with what the checkout left in git config, which it does unless the command line names a URL carrying its own credentials.
|
|
1509
|
+
*/
|
|
1510
|
+
function pushesWithGitConfig(line) {
|
|
1511
|
+
return !URL_WITH_CREDENTIALS.test(line);
|
|
1512
|
+
}
|
|
1513
|
+
function usesDefaultToken(workflow, job, step) {
|
|
1514
|
+
return DEFAULT_TOKEN.test([
|
|
1515
|
+
step.run,
|
|
1516
|
+
...Object.values(effectiveEnv(workflow, job, step)),
|
|
1517
|
+
...Object.values(step.with)
|
|
1518
|
+
].filter((text) => text !== void 0).join("\n"));
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Whether the job's checkout leaves the default token in the git config, so a `git push` authenticates as it.
|
|
1522
|
+
*/
|
|
1523
|
+
function checkoutKeepsDefaultToken(job) {
|
|
1524
|
+
return job.steps.some((step) => stepUses(step, CHECKOUT_ACTION) && (step.with["token"] === void 0 || DEFAULT_TOKEN.test(step.with["token"])) && step.with["persist-credentials"] !== "false");
|
|
1525
|
+
}
|
|
1526
|
+
function checkoutRefOtherThanDefault(job, branches) {
|
|
1527
|
+
return job.steps.some((step) => stepUses(step, CHECKOUT_ACTION) && step.with["ref"] !== void 0 && !isDefaultBranch(step.with["ref"], branches));
|
|
1528
|
+
}
|
|
1529
|
+
function stepViolations(context, step, onOtherBranch) {
|
|
1530
|
+
const { workflow, job, branches } = context;
|
|
1531
|
+
const lines = scriptCommands(step.run);
|
|
1532
|
+
const found = [];
|
|
1533
|
+
const report = (reason, message) => {
|
|
1534
|
+
found.push(workflowViolation(workflow, `workflow-repository-dispatch/${reason}`, message, step.location));
|
|
1535
|
+
};
|
|
1536
|
+
const pushes = lines.filter((line) => PUSH.test(line));
|
|
1537
|
+
const autoCommits = stepUses(step, AUTO_COMMIT_ACTION);
|
|
1538
|
+
const autoCommitBranch = step.with["branch"];
|
|
1539
|
+
if (autoCommits && !onOtherBranch && (autoCommitBranch === void 0 || isDefaultBranch(autoCommitBranch, branches)) || pushes.some((line) => pushesToDefault(line, onOtherBranch, branches))) report("pushes-default-branch", `job '${job.id}' pushes to the default branch from a repository_dispatch handler, which anyone who can dispatch can trigger, bypassing review; push a branch and open a pull request`);
|
|
1540
|
+
if (!context.assumeRequiredChecks && lines.some((line) => AUTO_MERGE.test(line))) report("auto-merge-without-required-checks", `job '${job.id}' merges with --auto, which merges at once when the base branch has no required checks; require status checks on the default branch (or set assumeRequiredChecks once it does)`);
|
|
1541
|
+
const pushesAsDefaultToken = (pushes.some(pushesWithGitConfig) || autoCommits) && checkoutKeepsDefaultToken(job);
|
|
1542
|
+
const createsPullRequestAsDefaultToken = lines.some((line) => PULL_REQUEST_CREATION.test(line)) && usesDefaultToken(workflow, job, step) || stepUses(step, PULL_REQUEST_ACTION) && (step.with["token"] === void 0 || DEFAULT_TOKEN.test(step.with["token"]));
|
|
1543
|
+
if (pushesAsDefaultToken || createsPullRequestAsDefaultToken) report("default-token-triggers-nothing", `job '${job.id}' pushes or opens a pull request as GITHUB_TOKEN, and events GitHub Actions creates with that token start no workflows, so nothing validates the result; use a token from a GitHub App or a personal access token`);
|
|
1544
|
+
const targets = [...lines.flatMap((line) => DISPATCH_TARGETS.map((pattern) => pattern.exec(line)?.[1])), ...stepUses(step, DISPATCH_ACTION) ? [step.with["repository"]] : []].filter((target) => target !== void 0);
|
|
1545
|
+
for (const target of targets.filter((candidate) => !candidate.includes("github.repository"))) if (usesDefaultToken(workflow, job, step)) report("dispatch-other-repository-with-default-token", `job '${job.id}' dispatches to ${target} with GITHUB_TOKEN, which is limited to this repository; use a token that has access to ${target}`);
|
|
1546
|
+
return found;
|
|
1547
|
+
}
|
|
1548
|
+
function jobViolations(context) {
|
|
1549
|
+
let onOtherBranch = checkoutRefOtherThanDefault(context.job, context.branches);
|
|
1550
|
+
const found = [];
|
|
1551
|
+
for (const step of context.job.steps) {
|
|
1552
|
+
found.push(...stepViolations(context, step, onOtherBranch));
|
|
1553
|
+
onOtherBranch ||= scriptCommands(step.run).some((line) => BRANCH_CREATION.test(line));
|
|
1554
|
+
}
|
|
1555
|
+
return found;
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* A workflow that handles `repository_dispatch` can be started by anyone with write access through the API, so what it does with the repository is held to a higher bar.
|
|
1559
|
+
*
|
|
1560
|
+
* `workflow-repository-dispatch/pushes-default-branch`: a `git push` to the default branch, or one with no refspec while the default branch is checked out, or `git-auto-commit-action` on it. `auto-merge-without-required-checks`: `gh pr merge --auto`, which merges at once when the base branch requires no checks (the file cannot show the rule; `assumeRequiredChecks` states it, and `settings-required-checks` verifies it). `default-token-triggers-nothing`: a push (the checkout leaves `GITHUB_TOKEN` in git config unless it is given a `token`, and a push to a URL carrying its own credentials does not use it) or a pull request opened as `GITHUB_TOKEN`; events created with it start no workflows, apart from `workflow_dispatch` and `repository_dispatch`, so the pushed commit gets no checks. `dispatch-other-repository-with-default-token`: a dispatch to a repository other than `github.repository` with `GITHUB_TOKEN`, whose access is limited to its own repository. Dispatching in the same repository with `GITHUB_TOKEN` is allowed by GitHub and is not reported.
|
|
1561
|
+
*
|
|
1562
|
+
* Limits: commands are read as text, so a script file or composite action is not seen; a push is judged by its command line, so a branch created by a helper is not seen, and a quoted word that contains a space is not read as one word; a dispatch target written literally as this repository's own name is reported, since the file does not know the name; a job that calls a reusable workflow is not read.
|
|
1563
|
+
*/
|
|
1564
|
+
const workflowRepositoryDispatch = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).filter((workflow) => workflow.triggers.has("repository_dispatch")).flatMap((workflow) => workflow.jobs.flatMap((job) => jobViolations({
|
|
1565
|
+
workflow,
|
|
1566
|
+
job,
|
|
1567
|
+
branches: options.defaultBranches ?? DEFAULT_BRANCHES,
|
|
1568
|
+
assumeRequiredChecks: options.assumeRequiredChecks === true
|
|
1569
|
+
})));
|
|
1570
|
+
//#endregion
|
|
1571
|
+
//#region src/checks/workflow-runner-resolution.ts
|
|
1572
|
+
/**
|
|
1573
|
+
* GitHub's own images: `ubuntu`, `windows` and `macos`, as `-latest`, a version, or a version with an architecture or size suffix, and `ubuntu-slim`.
|
|
1574
|
+
*/
|
|
1575
|
+
const DEFAULT_HOSTED_LABELS = ["^(?:ubuntu|windows|macos)-(?:latest|slim|[0-9][0-9.]*)(?:-(?:arm|intel|xlarge|large))*$"];
|
|
1576
|
+
/**
|
|
1577
|
+
* `fromJson(needs.<job>.outputs.<name>)` in a `runs-on`.
|
|
1578
|
+
*/
|
|
1579
|
+
const RESOLVED_RUNNER = /fromJSON\(\s*needs\.([\w-]+)\.outputs\.([\w-]+)\s*\)/giu;
|
|
1580
|
+
/**
|
|
1581
|
+
* `${{ jobs.<job>.outputs.<name> }}` as the value of a reusable workflow's output.
|
|
1582
|
+
*/
|
|
1583
|
+
const JOB_OUTPUT_REFERENCE = /^\$\{\{\s*jobs\.([\w-]+)\.outputs\.([\w-]+)\s*\}\}$/u;
|
|
1584
|
+
/**
|
|
1585
|
+
* A fallback in an output expression: `||` followed by a string literal, whose body may hold `''` for a quote.
|
|
1586
|
+
*/
|
|
1587
|
+
const LITERAL_FALLBACK = /\|\|\s*'((?:[^']|'')*)'/gu;
|
|
1588
|
+
/**
|
|
1589
|
+
* The resolver behind each `fromJson(needs.<job>.outputs.<name>)` in a `runs-on` of the workflow; none for one this check cannot read.
|
|
1590
|
+
*/
|
|
1591
|
+
function resolversOf(workflow, workflows) {
|
|
1592
|
+
return workflow.jobs.flatMap((job) => job.runsOn.expressions.flatMap((expression) => [...expression.matchAll(RESOLVED_RUNNER)].flatMap((match) => {
|
|
1593
|
+
const caller = workflow.jobs.find((candidate) => candidate.id === match[1]);
|
|
1594
|
+
const output = match[2];
|
|
1595
|
+
const resolver = caller === void 0 || output === void 0 ? void 0 : implementation(workflow, workflows, caller, output);
|
|
1596
|
+
return resolver === void 0 ? [] : [resolver];
|
|
1597
|
+
})));
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* The job that produces `output` for `caller`: the job itself, or for a call of a local reusable workflow the job in that workflow the output is forwarded from. `undefined` when the reusable workflow is not a file of this repository, which this check cannot read.
|
|
1601
|
+
*/
|
|
1602
|
+
function implementation(workflow, workflows, caller, output) {
|
|
1603
|
+
if (caller.uses === void 0) return {
|
|
1604
|
+
workflow,
|
|
1605
|
+
job: caller,
|
|
1606
|
+
output
|
|
1607
|
+
};
|
|
1608
|
+
const reference = parseUses(caller.uses);
|
|
1609
|
+
if (reference.kind !== "local") return;
|
|
1610
|
+
const called = workflows.find((candidate) => `./${candidate.file}` === reference.path);
|
|
1611
|
+
if (called === void 0) throw new ConformanceError(`${workflow.file}: job '${caller.id}' calls ${caller.uses}, which is not a workflow file`);
|
|
1612
|
+
const forwarded = JOB_OUTPUT_REFERENCE.exec(called.callOutputs[output] ?? "");
|
|
1613
|
+
const job = called.jobs.find((candidate) => candidate.id === forwarded?.[1]);
|
|
1614
|
+
const jobOutput = forwarded?.[2];
|
|
1615
|
+
return job === void 0 || jobOutput === void 0 ? void 0 : {
|
|
1616
|
+
workflow: called,
|
|
1617
|
+
job,
|
|
1618
|
+
output: jobOutput
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* The runner labels the last literal fallback of an output expression names, read as the JSON the `fromJson` that consumes it expects: a label or a list of labels. `undefined` when there is no literal fallback, and an empty list when the literal is not labels.
|
|
1623
|
+
*/
|
|
1624
|
+
function fallbackLabels(expression) {
|
|
1625
|
+
const literal = [...expression.matchAll(LITERAL_FALLBACK)].at(-1)?.[1]?.replaceAll("''", "'");
|
|
1626
|
+
if (literal === void 0) return;
|
|
1627
|
+
let parsed;
|
|
1628
|
+
try {
|
|
1629
|
+
parsed = JSON.parse(literal);
|
|
1630
|
+
} catch {
|
|
1631
|
+
return [];
|
|
1632
|
+
}
|
|
1633
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
1634
|
+
const labels = items.filter((item) => typeof item === "string");
|
|
1635
|
+
return labels.length === items.length ? labels : [];
|
|
1636
|
+
}
|
|
1637
|
+
function resolverViolations(resolver, hosted) {
|
|
1638
|
+
const violations = [];
|
|
1639
|
+
if (resolver.job.timeoutMinutes === void 0) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-no-timeout", `resolver job '${resolver.job.id}' has no timeout-minutes, so a runner that never starts holds every job waiting for it until the default six hours`, resolver.job.location));
|
|
1640
|
+
const fallback = fallbackLabels(resolver.job.outputs[resolver.output] ?? "");
|
|
1641
|
+
if (fallback === void 0) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-no-fallback", `output '${resolver.output}' of resolver job '${resolver.job.id}' has no literal fallback (\`|| '["<hosted label>"]'\`), so the jobs that read it have no runner when resolution produces nothing`, resolver.job.location));
|
|
1642
|
+
else if (fallback.length === 0 || !fallback.every((label) => hosted.some((pattern) => pattern.test(label)))) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-fallback-not-hosted", `the literal fallback of output '${resolver.output}' of resolver job '${resolver.job.id}' does not name only hosted runner labels, so it offers no runner when the self-hosted fleet is down`, resolver.job.location));
|
|
1643
|
+
return violations;
|
|
1644
|
+
}
|
|
1645
|
+
function customLabels(job, hosted) {
|
|
1646
|
+
return /* @__PURE__ */ new Set([...job.runsOn.labels.filter((label) => !hosted.some((pattern) => pattern.test(label))), ...job.runsOn.group === void 0 ? [] : [`group:${job.runsOn.group}`]]);
|
|
1647
|
+
}
|
|
1648
|
+
function repeatedLabels(workflow, hosted) {
|
|
1649
|
+
const labelsByJob = workflow.jobs.map((job) => ({
|
|
1650
|
+
job,
|
|
1651
|
+
labels: customLabels(job, hosted)
|
|
1652
|
+
}));
|
|
1653
|
+
const users = (label) => labelsByJob.filter((entry) => entry.labels.has(label)).map((entry) => entry.job.id);
|
|
1654
|
+
return labelsByJob.flatMap(({ job, labels }) => {
|
|
1655
|
+
const repeated = [...labels].filter((label) => users(label).length > 1);
|
|
1656
|
+
return repeated.length === 0 ? [] : [workflowViolation(workflow, "workflow-runner-resolution/repeated-label", `job '${job.id}' names the runner label${repeated.length === 1 ? "" : "s"} ${repeated.map((label) => `'${label}'`).join(", ")} literally, like ${[...new Set(repeated.flatMap(users))].filter((id) => id !== job.id).map((id) => `'${id}'`).join(", ")}; resolve it once in a resolver job and read it with fromJson`, job.location)];
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* A self-hosted or custom runner label named literally in more than one job of a workflow is resolved once instead: a resolver job decides the runner and the others read it with `fromJson(needs.<resolver>.outputs.<name>)`. The resolver must have a `timeout-minutes` and its output a literal fallback that names only hosted runner labels, so jobs still get a runner when resolution fails or the self-hosted fleet is down.
|
|
1661
|
+
*
|
|
1662
|
+
* A label is custom when it matches none of `hostedLabels`; the default patterns recognise GitHub's standard images, so a larger runner is named by a custom label and counts. Labels are compared within a workflow file, not across files. A resolver that is a reusable workflow is followed when it is a file of this repository (the output is traced through `on.workflow_call.outputs` to the job that sets it); a resolver in another repository cannot be read and is not judged. An output is taken to have a fallback when its expression has `||` followed by a string literal, and the last such literal is read as JSON (a label or a list of labels) and matched against `hostedLabels`; a fallback that is not valid JSON, or names any other label, is reported.
|
|
1663
|
+
*/
|
|
1664
|
+
const workflowRunnerResolution = async ({ cwd, options }) => {
|
|
1665
|
+
const hosted = (options.hostedLabels ?? DEFAULT_HOSTED_LABELS).map((source) => new RegExp(source, "u"));
|
|
1666
|
+
const selected = await loadWorkflows(cwd, options);
|
|
1667
|
+
const available = await loadWorkflows(cwd, { workflows: DEFAULT_WORKFLOWS });
|
|
1668
|
+
const resolvers = /* @__PURE__ */ new Map();
|
|
1669
|
+
for (const workflow of selected) for (const resolver of resolversOf(workflow, available)) resolvers.set(`${resolver.workflow.file}#${resolver.job.id}#${resolver.output}`, resolver);
|
|
1670
|
+
return [...selected.flatMap((workflow) => repeatedLabels(workflow, hosted)), ...[...resolvers.values()].flatMap((resolver) => resolverViolations(resolver, hosted))];
|
|
1671
|
+
};
|
|
1672
|
+
//#endregion
|
|
1673
|
+
//#region src/checks/workflow-skippable-jobs.ts
|
|
1674
|
+
const DEFAULT_PATH_FILTER_ACTIONS = [
|
|
1675
|
+
"dorny/paths-filter",
|
|
1676
|
+
"tj-actions/changed-files",
|
|
1677
|
+
"step-security/changed-files"
|
|
1678
|
+
];
|
|
1679
|
+
/**
|
|
1680
|
+
* The events whose `paths` and `paths-ignore` decide whether the whole workflow runs for a pull request.
|
|
1681
|
+
*/
|
|
1682
|
+
const PULL_REQUEST_EVENTS = ["pull_request", "pull_request_target"];
|
|
1683
|
+
/**
|
|
1684
|
+
* The jobs a job `if` reads outputs from: `needs.<job>.outputs.<name>`.
|
|
1685
|
+
*/
|
|
1686
|
+
const NEEDS_OUTPUT = /\bneeds\.([\w-]+)\.outputs\./gu;
|
|
1687
|
+
function hasPathFilter(workflow, event) {
|
|
1688
|
+
const configuration = workflow.triggers.get(event);
|
|
1689
|
+
return isRecord(configuration) && ("paths" in configuration || "paths-ignore" in configuration);
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* The jobs a job's `if` reads path filter outputs from.
|
|
375
1693
|
*/
|
|
376
|
-
|
|
377
|
-
"
|
|
378
|
-
|
|
379
|
-
"ci",
|
|
380
|
-
"docs",
|
|
381
|
-
"feat",
|
|
382
|
-
"fix",
|
|
383
|
-
"perf",
|
|
384
|
-
"refactor",
|
|
385
|
-
"revert",
|
|
386
|
-
"style",
|
|
387
|
-
"test"
|
|
388
|
-
];
|
|
389
|
-
function isCustom(type) {
|
|
390
|
-
return !PRESET_COMMIT_TYPES.includes(type);
|
|
1694
|
+
function filterJobsRead(workflow, job, actions) {
|
|
1695
|
+
const read = [...(job.condition ?? "").matchAll(NEEDS_OUTPUT)].map((match) => match[1]);
|
|
1696
|
+
return workflow.jobs.filter((candidate) => read.includes(candidate.id) && candidate.steps.some((step) => actions.some((action) => stepUses(step, action)))).map((candidate) => candidate.id);
|
|
391
1697
|
}
|
|
392
1698
|
/**
|
|
393
|
-
*
|
|
1699
|
+
* Keeping work off a pull request must not leave a required check without a result. A `paths` or `paths-ignore` filter on the trigger of a workflow with a junction job skips the whole workflow, so the required check stays pending forever; a job whose own `if` reads a path filter's outputs is skipped, which is only safe when a junction job with `if: always()` needs it and reports for it. Filter the steps of the job instead and the job still runs and reports.
|
|
394
1700
|
*
|
|
395
|
-
*
|
|
1701
|
+
* Limits: a workflow is taken to produce a required check when it has a junction job, so a trigger path filter in a workflow without one is not reported; a path filter is recognised by the actions in the options, so a script that computes changed paths itself is not seen, and a job gated on the output of such a script is not reported; only the filters of the `pull_request` and `pull_request_target` triggers are read, so a `paths` filter on `push` is not reported; only `needs.<job>.outputs` in the job's own `if` is read. Whether the junction job really reports is judged by `workflow-job-ordering`.
|
|
396
1702
|
*/
|
|
397
|
-
const
|
|
398
|
-
const commitlintFile = options.commitlint ?? DEFAULT_COMMITLINT_CONFIG;
|
|
399
|
-
const releaseFile = options.release ?? DEFAULT_RELEASE_CONFIG;
|
|
400
|
-
const commitlint = await readObject(cwd, commitlintFile, configFiles);
|
|
401
|
-
const release = await readObject(cwd, releaseFile, configFiles);
|
|
1703
|
+
const workflowSkippableJobs = async ({ cwd, options }) => {
|
|
402
1704
|
const violations = [];
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (commitlint === void 0 || release === void 0) return violations;
|
|
414
|
-
const releaseRules = typesOfEntries(pluginOptions(release, COMMIT_ANALYZER)?.["releaseRules"], releaseFile, "releaseRules");
|
|
415
|
-
const presetConfig = pluginOptions(release, RELEASE_NOTES_GENERATOR)?.["presetConfig"];
|
|
416
|
-
const changelog = typesOfEntries(isRecord(presetConfig) ? presetConfig["types"] : void 0, releaseFile, "presetConfig.types");
|
|
417
|
-
const accepted = commitTypeRule(commitlint, commitlintFile);
|
|
418
|
-
if (accepted === void 0) {
|
|
419
|
-
const unknown = [releaseRules, changelog].flatMap((list) => list ?? []).filter(isCustom);
|
|
420
|
-
if (unknown.length > 0) violations.push({
|
|
421
|
-
code: "commit-types/no-type-enum",
|
|
422
|
-
message: `${releaseFile} lists ${unknown.map((type) => `'${type}'`).join(", ")}, which no preset knows, but ${commitlintFile} sets no 'type-enum' rule, so the commit types cannot be read`,
|
|
423
|
-
file: commitlintFile
|
|
424
|
-
});
|
|
425
|
-
return violations;
|
|
426
|
-
}
|
|
427
|
-
const lists = [{
|
|
428
|
-
listed: releaseRules,
|
|
429
|
-
required: true,
|
|
430
|
-
noun: "release rule",
|
|
431
|
-
absent: "no-release-rule",
|
|
432
|
-
extra: "release-rule-not-a-commit-type"
|
|
433
|
-
}, {
|
|
434
|
-
listed: changelog,
|
|
435
|
-
required: changelog !== void 0,
|
|
436
|
-
noun: "changelog section",
|
|
437
|
-
absent: "no-changelog-section",
|
|
438
|
-
extra: "changelog-section-not-a-commit-type"
|
|
439
|
-
}];
|
|
440
|
-
for (const { listed, required, noun, absent, extra } of lists) {
|
|
441
|
-
for (const type of required ? (accepted.enumerated ?? []).filter(isCustom) : []) if (listed?.includes(type) !== true) violations.push({
|
|
442
|
-
code: `commit-types/${absent}`,
|
|
443
|
-
message: `'${type}' is a commit type in ${commitlintFile} that no preset knows, and ${releaseFile} has no ${noun} for it`,
|
|
444
|
-
file: releaseFile
|
|
445
|
-
});
|
|
446
|
-
for (const type of (listed ?? []).filter((candidate) => !accepted.accepts(candidate))) violations.push({
|
|
447
|
-
code: `commit-types/${extra}`,
|
|
448
|
-
message: `'${type}' has a ${noun} in ${releaseFile} but is not a commit type in ${commitlintFile}`,
|
|
449
|
-
file: commitlintFile
|
|
450
|
-
});
|
|
1705
|
+
for (const workflow of await loadWorkflows(cwd, options)) {
|
|
1706
|
+
const junctions = jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS);
|
|
1707
|
+
if (junctions.length > 0) {
|
|
1708
|
+
for (const [event, location] of workflow.triggerLocations) if (PULL_REQUEST_EVENTS.includes(event) && hasPathFilter(workflow, event)) violations.push(workflowViolation(workflow, "workflow-skippable-jobs/trigger-path-filter", `${event} has a paths filter, so the workflow does not run, and its required check stays pending, when no matching file changed; filter the steps of the jobs instead`, location));
|
|
1709
|
+
}
|
|
1710
|
+
for (const job of workflow.jobs) {
|
|
1711
|
+
const filters = filterJobsRead(workflow, job, options.pathFilterActions ?? DEFAULT_PATH_FILTER_ACTIONS);
|
|
1712
|
+
const reported = junctions.some((junction) => junction.needs.includes(job.id));
|
|
1713
|
+
if (filters.length > 0 && !reported) violations.push(workflowViolation(workflow, "workflow-skippable-jobs/job-gated-by-path-filter", `job '${job.id}' is skipped by an if on the path filter in '${filters.join("', '")}' and no junction job reports for it; put the condition on its steps, or add the job to the needs of a junction job with if: always()`, job.location));
|
|
1714
|
+
}
|
|
451
1715
|
}
|
|
452
1716
|
return violations;
|
|
453
1717
|
};
|
|
454
1718
|
//#endregion
|
|
455
|
-
//#region src/checks/
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
"
|
|
459
|
-
"
|
|
1719
|
+
//#region src/checks/workflow-update-bot-cooldown.ts
|
|
1720
|
+
const DEPENDABOT_FILES = [".github/dependabot.yml", ".github/dependabot.yaml"];
|
|
1721
|
+
const RENOVATE_FILES = [
|
|
1722
|
+
"renovate.json",
|
|
1723
|
+
".renovaterc.json",
|
|
1724
|
+
".renovaterc",
|
|
1725
|
+
".github/renovate.json"
|
|
460
1726
|
];
|
|
461
|
-
const DEFAULT_PACKAGE_JSON = "package.json";
|
|
462
1727
|
/**
|
|
463
|
-
*
|
|
464
|
-
*/
|
|
465
|
-
const PACKAGE_MANAGER = /^([a-z]+)@([^+\s]+)/u;
|
|
466
|
-
/**
|
|
467
|
-
* A variable reference in a version: `$NAME` or `${NAME}`, capturing the name.
|
|
1728
|
+
* The cooldown settings that delay a release by a number of days.
|
|
468
1729
|
*/
|
|
469
|
-
const
|
|
1730
|
+
const COOLDOWN_DAYS = [
|
|
1731
|
+
"default-days",
|
|
1732
|
+
"semver-major-days",
|
|
1733
|
+
"semver-minor-days",
|
|
1734
|
+
"semver-patch-days"
|
|
1735
|
+
];
|
|
1736
|
+
const RENOVATE_SETTING = "minimumReleaseAge";
|
|
1737
|
+
function firstExisting$1(cwd, explicit, defaults) {
|
|
1738
|
+
if (explicit !== void 0) {
|
|
1739
|
+
if (!existsSync(resolve(cwd, explicit))) throw new ConformanceError(`${explicit} does not exist`);
|
|
1740
|
+
return explicit;
|
|
1741
|
+
}
|
|
1742
|
+
return defaults.find((file) => existsSync(resolve(cwd, file)));
|
|
1743
|
+
}
|
|
1744
|
+
function hasCooldown(update) {
|
|
1745
|
+
if (!isRecord(update) || !isRecord(update["cooldown"])) return false;
|
|
1746
|
+
const cooldown = update["cooldown"];
|
|
1747
|
+
return COOLDOWN_DAYS.some((setting) => typeof cooldown[setting] === "number" && cooldown[setting] > 0);
|
|
1748
|
+
}
|
|
1749
|
+
async function dependabotViolations(cwd, file) {
|
|
1750
|
+
const parsed = parseYaml(file, await readFile(resolve(cwd, file), "utf8"));
|
|
1751
|
+
return (isRecord(parsed.value) && Array.isArray(parsed.value["updates"]) ? parsed.value["updates"] : []).flatMap((update, index) => hasCooldown(update) ? [] : [{
|
|
1752
|
+
code: "workflow-update-bot-cooldown/dependabot-no-cooldown",
|
|
1753
|
+
message: `the update entry for ${isRecord(update) && typeof update["package-ecosystem"] === "string" ? update["package-ecosystem"] : `entry ${String(index)}`} has no cooldown with a number of days above zero, so Dependabot proposes a release the day it is published`,
|
|
1754
|
+
file,
|
|
1755
|
+
location: parsed.locate(["updates", index])
|
|
1756
|
+
}]);
|
|
1757
|
+
}
|
|
470
1758
|
/**
|
|
471
|
-
*
|
|
1759
|
+
* Whether a `minimumReleaseAge` value waits for a positive time: a duration string such as `3 days`, whose number is above zero. `0 days` and `null` (which clears the setting) wait for nothing.
|
|
472
1760
|
*/
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
1761
|
+
function delaysRelease(age) {
|
|
1762
|
+
const amount = typeof age === "string" ? /^\s*(\d+(?:\.\d+)?)\s*[a-z]/iu.exec(age)?.[1] : void 0;
|
|
1763
|
+
return amount !== void 0 && Number(amount) > 0;
|
|
1764
|
+
}
|
|
1765
|
+
function setsMinimumReleaseAge(value) {
|
|
1766
|
+
if (!isRecord(value)) return false;
|
|
1767
|
+
const rules = Array.isArray(value["packageRules"]) ? value["packageRules"] : [];
|
|
1768
|
+
const presets = Array.isArray(value["extends"]) ? value["extends"] : [];
|
|
1769
|
+
return delaysRelease(value[RENOVATE_SETTING]) || rules.some((rule) => isRecord(rule) && delaysRelease(rule[RENOVATE_SETTING])) || presets.some((preset) => typeof preset === "string" && preset.includes(RENOVATE_SETTING));
|
|
1770
|
+
}
|
|
1771
|
+
async function renovateViolations(cwd, file) {
|
|
1772
|
+
const errors = [];
|
|
1773
|
+
const value = parse$1(await readFile(resolve(cwd, file), "utf8"), errors, { allowTrailingComma: true });
|
|
1774
|
+
const [error] = errors;
|
|
1775
|
+
if (error !== void 0) throw new ConformanceError(`${file}: not valid JSON: ${printParseErrorCode(error.error)} at offset ${String(error.offset)}`);
|
|
1776
|
+
return setsMinimumReleaseAge(value) ? [] : [{
|
|
1777
|
+
code: "workflow-update-bot-cooldown/renovate-no-minimum-release-age",
|
|
1778
|
+
message: `${file} sets no ${RENOVATE_SETTING} above zero, so Renovate proposes a release the day it is published`,
|
|
1779
|
+
file
|
|
1780
|
+
}];
|
|
478
1781
|
}
|
|
479
1782
|
/**
|
|
480
|
-
* The
|
|
1783
|
+
* The update bots wait before proposing a release, so a compromised release is withdrawn before it is installed. Dependabot: every `updates` entry needs a `cooldown` with `default-days` or one of the `semver-*-days` above zero. Renovate: `minimumReleaseAge` must be set to a duration above zero at the top level or in a `packageRules` entry.
|
|
1784
|
+
*
|
|
1785
|
+
* Limits: the bots' own rules differ, since Dependabot's cooldown does not apply to security updates, and a Renovate preset is not resolved (an `extends` entry counts only when its name contains `minimumReleaseAge`), so a setting that comes from a preset under another name is reported. Renovate configs in JSON5 syntax beyond comments and trailing commas, and a `renovate` key in `package.json`, are not read. A repository with neither bot has nothing to report.
|
|
481
1786
|
*/
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
1787
|
+
const workflowUpdateBotCooldown = async ({ cwd, options }) => {
|
|
1788
|
+
const dependabot = firstExisting$1(cwd, options.dependabot, DEPENDABOT_FILES);
|
|
1789
|
+
const renovate = firstExisting$1(cwd, options.renovate, RENOVATE_FILES);
|
|
1790
|
+
return [...dependabot === void 0 ? [] : await dependabotViolations(cwd, dependabot), ...renovate === void 0 ? [] : await renovateViolations(cwd, renovate)];
|
|
1791
|
+
};
|
|
1792
|
+
//#endregion
|
|
1793
|
+
//#region src/checks/workflow-version-single-source.ts
|
|
1794
|
+
const TOOL_VERSIONS = ".tool-versions";
|
|
1795
|
+
const NVMRC = ".nvmrc";
|
|
1796
|
+
const SETUP_ACTIONS = [
|
|
1797
|
+
{
|
|
1798
|
+
action: "actions/setup-node",
|
|
1799
|
+
versionInput: "node-version",
|
|
1800
|
+
fileInput: "node-version-file",
|
|
1801
|
+
tools: ["nodejs", "node"]
|
|
1802
|
+
},
|
|
1803
|
+
{
|
|
1804
|
+
action: "actions/setup-python",
|
|
1805
|
+
versionInput: "python-version",
|
|
1806
|
+
fileInput: "python-version-file",
|
|
1807
|
+
tools: ["python"]
|
|
1808
|
+
},
|
|
1809
|
+
{
|
|
1810
|
+
action: "actions/setup-go",
|
|
1811
|
+
versionInput: "go-version",
|
|
1812
|
+
fileInput: "go-version-file",
|
|
1813
|
+
tools: ["golang", "go"]
|
|
1814
|
+
},
|
|
1815
|
+
{
|
|
1816
|
+
action: "actions/setup-java",
|
|
1817
|
+
versionInput: "java-version",
|
|
1818
|
+
fileInput: "java-version-file",
|
|
1819
|
+
tools: ["java"]
|
|
1820
|
+
},
|
|
1821
|
+
{
|
|
1822
|
+
action: "actions/setup-dotnet",
|
|
1823
|
+
versionInput: "dotnet-version",
|
|
1824
|
+
fileInput: "global-json-file",
|
|
1825
|
+
tools: ["dotnet", "dotnet-core"]
|
|
1826
|
+
},
|
|
1827
|
+
{
|
|
1828
|
+
action: "ruby/setup-ruby",
|
|
1829
|
+
versionInput: "ruby-version",
|
|
1830
|
+
fileInput: void 0,
|
|
1831
|
+
tools: ["ruby"]
|
|
1832
|
+
},
|
|
1833
|
+
{
|
|
1834
|
+
action: "oven-sh/setup-bun",
|
|
1835
|
+
versionInput: "bun-version",
|
|
1836
|
+
fileInput: "bun-version-file",
|
|
1837
|
+
tools: ["bun"]
|
|
1838
|
+
},
|
|
1839
|
+
{
|
|
1840
|
+
action: "denoland/setup-deno",
|
|
1841
|
+
versionInput: "deno-version",
|
|
1842
|
+
fileInput: "deno-version-file",
|
|
1843
|
+
tools: ["deno"]
|
|
1844
|
+
},
|
|
1845
|
+
{
|
|
1846
|
+
action: "pnpm/action-setup",
|
|
1847
|
+
versionInput: "version",
|
|
1848
|
+
fileInput: void 0,
|
|
1849
|
+
tools: ["pnpm"]
|
|
1850
|
+
},
|
|
1851
|
+
{
|
|
1852
|
+
action: "hashicorp/setup-terraform",
|
|
1853
|
+
versionInput: "terraform_version",
|
|
1854
|
+
fileInput: void 0,
|
|
1855
|
+
tools: ["terraform"]
|
|
490
1856
|
}
|
|
491
|
-
|
|
492
|
-
}
|
|
1857
|
+
];
|
|
493
1858
|
/**
|
|
494
|
-
* The
|
|
1859
|
+
* The tool names a `.tool-versions` file lists. Each line is a tool name followed by versions; a comment starts with `#`.
|
|
495
1860
|
*/
|
|
496
|
-
function
|
|
497
|
-
|
|
498
|
-
const replaced = token.replace(VARIABLE, (_reference, variable) => {
|
|
499
|
-
const value = variables.get(variable);
|
|
500
|
-
if (value === void 0 || value.includes("$")) {
|
|
501
|
-
unknown.push(variable);
|
|
502
|
-
return "";
|
|
503
|
-
}
|
|
504
|
-
return value;
|
|
505
|
-
});
|
|
506
|
-
return unknown.length === 0 ? replaced.split("+")[0] : void 0;
|
|
1861
|
+
function listedTools(content) {
|
|
1862
|
+
return new Set(content.split("\n").map((line) => line.replace(/#.*$/u, "").trim().split(/\s+/u)).filter((fields) => fields.length >= 2).map((fields) => String(fields[0])));
|
|
507
1863
|
}
|
|
508
1864
|
/**
|
|
509
|
-
*
|
|
1865
|
+
* A setup step keeps a runtime version in the workflow when a version file already says it: `.nvmrc` for Node, `.tool-versions` for any tool it lists. The step reads the file instead (`node-version-file`, or `mise` or `asdf` actions), so there is one place to change.
|
|
510
1866
|
*
|
|
511
|
-
*
|
|
1867
|
+
* A version is a literal when it contains no expression, so `${{ matrix.node }}` is not reported: a matrix tests versions on purpose. A range or alias such as `lts/*` is a literal. Only the setup actions listed in the README are recognised, only the files in the working directory are looked for, and composite actions are not read.
|
|
512
1868
|
*/
|
|
513
|
-
const
|
|
514
|
-
const
|
|
515
|
-
const
|
|
516
|
-
const declared = isRecord(manifest) && typeof manifest["packageManager"] === "string" ? PACKAGE_MANAGER.exec(manifest["packageManager"]) : null;
|
|
517
|
-
const name = declared?.[1];
|
|
518
|
-
const version = declared?.[2];
|
|
519
|
-
if (name === void 0 || version === void 0) return [{
|
|
520
|
-
code: "dockerfile-package-manager/no-package-manager",
|
|
521
|
-
message: `${packageJson} has no valid 'packageManager' field to compare Dockerfile pins with`,
|
|
522
|
-
file: packageJson
|
|
523
|
-
}];
|
|
1869
|
+
const workflowVersionSingleSource = async ({ cwd, options }) => {
|
|
1870
|
+
const toolVersions = existsSync(resolve(cwd, TOOL_VERSIONS)) ? listedTools(await readFile(resolve(cwd, TOOL_VERSIONS), "utf8")) : /* @__PURE__ */ new Set();
|
|
1871
|
+
const hasNvmrc = existsSync(resolve(cwd, NVMRC));
|
|
524
1872
|
const violations = [];
|
|
525
|
-
for (const
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
for (const [variable, value] of variablesSetBy(text)) variables.set(variable, value);
|
|
530
|
-
for (const match of text.matchAll(PIN)) {
|
|
531
|
-
const pinned = versionOf(String(match[2]), variables);
|
|
532
|
-
if (match[1] === name && pinned !== void 0 && pinned !== version) violations.push({
|
|
533
|
-
code: "dockerfile-package-manager/version-mismatch",
|
|
534
|
-
message: `${file} pins ${name}@${pinned} but packageManager in ${packageJson} is ${name}@${version}`,
|
|
535
|
-
file,
|
|
536
|
-
location: {
|
|
537
|
-
line: index + 1,
|
|
538
|
-
column: match.index + 1
|
|
539
|
-
}
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
});
|
|
1873
|
+
for (const workflow of await loadWorkflows(cwd, options)) for (const job of workflow.jobs) for (const step of job.steps) for (const setup of SETUP_ACTIONS.filter((candidate) => stepUses(step, candidate.action))) {
|
|
1874
|
+
const version = step.with[setup.versionInput];
|
|
1875
|
+
const source = setup.action === "actions/setup-node" && hasNvmrc ? NVMRC : setup.tools.find((tool) => toolVersions.has(tool)) === void 0 ? void 0 : TOOL_VERSIONS;
|
|
1876
|
+
if (version !== void 0 && !hasExpression(version) && source !== void 0) violations.push(workflowViolation(workflow, "workflow-version-single-source/literal-version", `${setup.action} sets ${setup.versionInput}: ${version}, but ${source} already holds the version; read it from there${setup.fileInput === void 0 ? "" : ` with ${setup.fileInput}`}`, step.location));
|
|
543
1877
|
}
|
|
544
1878
|
return violations;
|
|
545
1879
|
};
|
|
@@ -1163,45 +2497,6 @@ const importUphillSpec = {
|
|
|
1163
2497
|
message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), a higher rank`
|
|
1164
2498
|
})
|
|
1165
2499
|
};
|
|
1166
|
-
async function git(cwd, args) {
|
|
1167
|
-
return new Promise((resolve, reject) => {
|
|
1168
|
-
execFile("git", [...args], {
|
|
1169
|
-
cwd,
|
|
1170
|
-
encoding: "utf8"
|
|
1171
|
-
}, (error, stdout) => {
|
|
1172
|
-
if (error === null) resolve(stdout);
|
|
1173
|
-
else reject(new ConformanceError(`git ${args.join(" ")} failed in ${cwd}; this check reads the git index, so it needs a git working tree`, { cause: error }));
|
|
1174
|
-
});
|
|
1175
|
-
});
|
|
1176
|
-
}
|
|
1177
|
-
/**
|
|
1178
|
-
* The index entry for `path` (relative to `cwd`), or `undefined` when git does not track it.
|
|
1179
|
-
*/
|
|
1180
|
-
async function indexEntry(cwd, path) {
|
|
1181
|
-
const listing = await git(cwd, [
|
|
1182
|
-
"ls-files",
|
|
1183
|
-
"--stage",
|
|
1184
|
-
"--",
|
|
1185
|
-
path
|
|
1186
|
-
]);
|
|
1187
|
-
const match = /^(\d+) ([0-9a-f]+) \d\t/u.exec(listing);
|
|
1188
|
-
const mode = match?.[1];
|
|
1189
|
-
const blob = match?.[2];
|
|
1190
|
-
return mode === void 0 || blob === void 0 ? void 0 : {
|
|
1191
|
-
mode,
|
|
1192
|
-
blob
|
|
1193
|
-
};
|
|
1194
|
-
}
|
|
1195
|
-
/**
|
|
1196
|
-
* The content of a blob. For a symbolic link it is the link's target.
|
|
1197
|
-
*/
|
|
1198
|
-
async function blobContent(cwd, blob) {
|
|
1199
|
-
return git(cwd, [
|
|
1200
|
-
"cat-file",
|
|
1201
|
-
"blob",
|
|
1202
|
-
blob
|
|
1203
|
-
]);
|
|
1204
|
-
}
|
|
1205
2500
|
//#endregion
|
|
1206
2501
|
//#region src/checks/instruction-symlinks.ts
|
|
1207
2502
|
/**
|
|
@@ -1503,6 +2798,7 @@ function plain(spec) {
|
|
|
1503
2798
|
name: spec.name,
|
|
1504
2799
|
description: spec.description,
|
|
1505
2800
|
requiresLayout: false,
|
|
2801
|
+
requiresGitHub: false,
|
|
1506
2802
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
1507
2803
|
run: async ({ cwd, checks, configFiles }) => {
|
|
1508
2804
|
const options = spec.select(checks);
|
|
@@ -1515,11 +2811,32 @@ function plain(spec) {
|
|
|
1515
2811
|
}
|
|
1516
2812
|
};
|
|
1517
2813
|
}
|
|
2814
|
+
function online(spec) {
|
|
2815
|
+
return {
|
|
2816
|
+
name: spec.name,
|
|
2817
|
+
description: spec.description,
|
|
2818
|
+
requiresLayout: false,
|
|
2819
|
+
requiresGitHub: true,
|
|
2820
|
+
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
2821
|
+
run: async ({ cwd, checks, configFiles, github }) => {
|
|
2822
|
+
const options = spec.select(checks);
|
|
2823
|
+
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
2824
|
+
if (github === void 0) throw new ConformanceError(`the check '${spec.name}' reads the repository's settings and needs a GitHub client`);
|
|
2825
|
+
return spec.check({
|
|
2826
|
+
cwd,
|
|
2827
|
+
options,
|
|
2828
|
+
github,
|
|
2829
|
+
...configFiles === void 0 ? {} : { configFiles }
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
1518
2834
|
function importCheck(spec) {
|
|
1519
2835
|
return {
|
|
1520
2836
|
name: spec.name,
|
|
1521
2837
|
description: spec.description,
|
|
1522
2838
|
requiresLayout: true,
|
|
2839
|
+
requiresGitHub: false,
|
|
1523
2840
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
1524
2841
|
importGraph: {
|
|
1525
2842
|
spec: spec.importSpec,
|
|
@@ -1602,6 +2919,78 @@ const registry = {
|
|
|
1602
2919
|
description: "The directory a schema generator writes migrations to is the one the deploy tool applies them from, and no script applies them with the generator",
|
|
1603
2920
|
select: (checks) => enabledOptions(checks["migrations-directory"]),
|
|
1604
2921
|
check: migrationsDirectory
|
|
2922
|
+
}),
|
|
2923
|
+
"workflow-job-ordering": plain({
|
|
2924
|
+
name: "workflow-job-ordering",
|
|
2925
|
+
description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
|
|
2926
|
+
select: (checks) => enabledOptions(checks["workflow-job-ordering"]),
|
|
2927
|
+
check: workflowJobOrdering
|
|
2928
|
+
}),
|
|
2929
|
+
"workflow-skippable-jobs": plain({
|
|
2930
|
+
name: "workflow-skippable-jobs",
|
|
2931
|
+
description: "A path filter does not leave a required check pending or skip a job no junction job reports for",
|
|
2932
|
+
select: (checks) => enabledOptions(checks["workflow-skippable-jobs"]),
|
|
2933
|
+
check: workflowSkippableJobs
|
|
2934
|
+
}),
|
|
2935
|
+
"workflow-runner-resolution": plain({
|
|
2936
|
+
name: "workflow-runner-resolution",
|
|
2937
|
+
description: "A self-hosted or custom runner label is resolved once by a resolver job, not repeated in several jobs",
|
|
2938
|
+
select: (checks) => enabledOptions(checks["workflow-runner-resolution"]),
|
|
2939
|
+
check: workflowRunnerResolution
|
|
2940
|
+
}),
|
|
2941
|
+
"workflow-version-single-source": plain({
|
|
2942
|
+
name: "workflow-version-single-source",
|
|
2943
|
+
description: "Setup steps read a runtime version from .nvmrc or .tool-versions instead of holding a literal",
|
|
2944
|
+
select: (checks) => enabledOptions(checks["workflow-version-single-source"]),
|
|
2945
|
+
check: workflowVersionSingleSource
|
|
2946
|
+
}),
|
|
2947
|
+
"workflow-credentials": plain({
|
|
2948
|
+
name: "workflow-credentials",
|
|
2949
|
+
description: "Attestation steps have the permissions they need, and a tokenless publish cannot quietly use a token",
|
|
2950
|
+
select: (checks) => enabledOptions(checks["workflow-credentials"]),
|
|
2951
|
+
check: workflowCredentials
|
|
2952
|
+
}),
|
|
2953
|
+
"workflow-repository-dispatch": plain({
|
|
2954
|
+
name: "workflow-repository-dispatch",
|
|
2955
|
+
description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
|
|
2956
|
+
select: (checks) => enabledOptions(checks["workflow-repository-dispatch"]),
|
|
2957
|
+
check: workflowRepositoryDispatch
|
|
2958
|
+
}),
|
|
2959
|
+
"workflow-update-bot-cooldown": plain({
|
|
2960
|
+
name: "workflow-update-bot-cooldown",
|
|
2961
|
+
description: "Dependabot and Renovate wait before proposing a new release",
|
|
2962
|
+
select: (checks) => enabledOptions(checks["workflow-update-bot-cooldown"]),
|
|
2963
|
+
check: workflowUpdateBotCooldown
|
|
2964
|
+
}),
|
|
2965
|
+
"workflow-merge-group": plain({
|
|
2966
|
+
name: "workflow-merge-group",
|
|
2967
|
+
description: "A workflow that runs for pull requests also runs for the merge queue",
|
|
2968
|
+
select: (checks) => enabledOptions(checks["workflow-merge-group"]),
|
|
2969
|
+
check: workflowMergeGroup
|
|
2970
|
+
}),
|
|
2971
|
+
"workflow-action-pinning": plain({
|
|
2972
|
+
name: "workflow-action-pinning",
|
|
2973
|
+
description: "Actions and reusable workflows are pinned as the configured policy requires",
|
|
2974
|
+
select: (checks) => enabledOptions(checks["workflow-action-pinning"]),
|
|
2975
|
+
check: workflowActionPinning
|
|
2976
|
+
}),
|
|
2977
|
+
"settings-merge-methods": online({
|
|
2978
|
+
name: "settings-merge-methods",
|
|
2979
|
+
description: "The repository allows only one merge method",
|
|
2980
|
+
select: (checks) => enabledOptions(checks["settings-merge-methods"]),
|
|
2981
|
+
check: settingsMergeMethods
|
|
2982
|
+
}),
|
|
2983
|
+
"settings-required-checks": online({
|
|
2984
|
+
name: "settings-required-checks",
|
|
2985
|
+
description: "The default branch requires the junction job check, up to date with the base",
|
|
2986
|
+
select: (checks) => enabledOptions(checks["settings-required-checks"]),
|
|
2987
|
+
check: settingsRequiredChecks
|
|
2988
|
+
}),
|
|
2989
|
+
"settings-review-thread-resolution": online({
|
|
2990
|
+
name: "settings-review-thread-resolution",
|
|
2991
|
+
description: "A pull request cannot merge while a review conversation is unresolved",
|
|
2992
|
+
select: (checks) => enabledOptions(checks["settings-review-thread-resolution"]),
|
|
2993
|
+
check: settingsReviewThreadResolution
|
|
1605
2994
|
})
|
|
1606
2995
|
};
|
|
1607
2996
|
/**
|
|
@@ -1632,6 +3021,15 @@ function compiles(source) {
|
|
|
1632
3021
|
*/
|
|
1633
3022
|
const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
|
|
1634
3023
|
const patterns = z.array(pattern);
|
|
3024
|
+
const selection = {
|
|
3025
|
+
workflows: z.exactOptional(paths),
|
|
3026
|
+
exclude: z.exactOptional(paths)
|
|
3027
|
+
};
|
|
3028
|
+
const pinningLevel = z.enum(["sha", "ref"]);
|
|
3029
|
+
const settings = {
|
|
3030
|
+
repository: z.exactOptional(z.string().regex(/^[^/\s]+\/[^/\s]+$/u, "expected owner/name")),
|
|
3031
|
+
branch: z.exactOptional(z.string().min(1))
|
|
3032
|
+
};
|
|
1635
3033
|
const importGraph = z.strictObject({
|
|
1636
3034
|
exclude: z.exactOptional(patterns),
|
|
1637
3035
|
doNotFollow: z.exactOptional(patterns),
|
|
@@ -1690,7 +3088,58 @@ const checks = z.strictObject({
|
|
|
1690
3088
|
database: z.exactOptional(z.string().min(1)),
|
|
1691
3089
|
environment: z.exactOptional(z.string().min(1)),
|
|
1692
3090
|
packageJsons: z.exactOptional(paths)
|
|
1693
|
-
})))
|
|
3091
|
+
}))),
|
|
3092
|
+
"workflow-job-ordering": z.exactOptional(enabled(z.strictObject({
|
|
3093
|
+
...selection,
|
|
3094
|
+
releaseJobs: z.exactOptional(names),
|
|
3095
|
+
deployJobs: z.exactOptional(names),
|
|
3096
|
+
docsDeployJobs: z.exactOptional(names),
|
|
3097
|
+
junctionJobs: z.exactOptional(names),
|
|
3098
|
+
junctionExempt: z.exactOptional(names),
|
|
3099
|
+
defaultBranch: z.exactOptional(z.string().min(1))
|
|
3100
|
+
}))),
|
|
3101
|
+
"workflow-skippable-jobs": z.exactOptional(enabled(z.strictObject({
|
|
3102
|
+
...selection,
|
|
3103
|
+
junctionJobs: z.exactOptional(names),
|
|
3104
|
+
pathFilterActions: z.exactOptional(names)
|
|
3105
|
+
}))),
|
|
3106
|
+
"workflow-runner-resolution": z.exactOptional(enabled(z.strictObject({
|
|
3107
|
+
...selection,
|
|
3108
|
+
hostedLabels: z.exactOptional(patterns)
|
|
3109
|
+
}))),
|
|
3110
|
+
"workflow-version-single-source": z.exactOptional(enabled(z.strictObject(selection))),
|
|
3111
|
+
"workflow-credentials": z.exactOptional(enabled(z.strictObject(selection))),
|
|
3112
|
+
"workflow-repository-dispatch": z.exactOptional(enabled(z.strictObject({
|
|
3113
|
+
...selection,
|
|
3114
|
+
defaultBranches: z.exactOptional(names),
|
|
3115
|
+
assumeRequiredChecks: z.exactOptional(z.boolean())
|
|
3116
|
+
}))),
|
|
3117
|
+
"workflow-update-bot-cooldown": z.exactOptional(enabled(z.strictObject({
|
|
3118
|
+
dependabot: z.exactOptional(path),
|
|
3119
|
+
renovate: z.exactOptional(path)
|
|
3120
|
+
}))),
|
|
3121
|
+
"workflow-merge-group": z.exactOptional(enabled(z.strictObject(selection))),
|
|
3122
|
+
"workflow-action-pinning": z.exactOptional(enabled(z.strictObject({
|
|
3123
|
+
...selection,
|
|
3124
|
+
thirdParty: z.exactOptional(pinningLevel),
|
|
3125
|
+
sameOrganisation: z.exactOptional(pinningLevel),
|
|
3126
|
+
organisations: z.exactOptional(names),
|
|
3127
|
+
allow: z.exactOptional(names)
|
|
3128
|
+
}))),
|
|
3129
|
+
"settings-merge-methods": z.exactOptional(enabled(z.strictObject({
|
|
3130
|
+
...settings,
|
|
3131
|
+
allowed: z.exactOptional(z.enum([
|
|
3132
|
+
"rebase",
|
|
3133
|
+
"squash",
|
|
3134
|
+
"merge"
|
|
3135
|
+
]))
|
|
3136
|
+
}))),
|
|
3137
|
+
"settings-required-checks": z.exactOptional(enabled(z.strictObject({
|
|
3138
|
+
...settings,
|
|
3139
|
+
...selection,
|
|
3140
|
+
junctionJobs: z.exactOptional(names)
|
|
3141
|
+
}))),
|
|
3142
|
+
"settings-review-thread-resolution": z.exactOptional(enabled(z.strictObject(settings)))
|
|
1694
3143
|
});
|
|
1695
3144
|
/**
|
|
1696
3145
|
* The Standard Schema for {@link ConformanceConfig}. It rejects unknown keys at every level, so a misspelt check name or option fails when the section loads.
|
|
@@ -1729,14 +3178,18 @@ async function layoutOf(options, needed) {
|
|
|
1729
3178
|
if (loaded === void 0) throw new ConformanceError(`${needed.join(", ")} read the workspace layout, and there is no 'layout' section in ${options.cwd}; define it in exadev.config.ts or in exadev.layout.config.ts`);
|
|
1730
3179
|
return loaded;
|
|
1731
3180
|
}
|
|
1732
|
-
function selected(config, requested) {
|
|
3181
|
+
function selected(config, requested, online) {
|
|
1733
3182
|
const enabled = checkNames.filter((name) => registry[name].isEnabled(config.checks));
|
|
1734
3183
|
if (requested === void 0) {
|
|
1735
3184
|
if (enabled.length === 0) throw new ConformanceError("no check is enabled; add one to the 'checks' of the 'conformance' section");
|
|
1736
|
-
|
|
3185
|
+
const runnable = enabled.filter((name) => online || !registry[name].requiresGitHub);
|
|
3186
|
+
if (runnable.length === 0) throw new ConformanceError(`only checks that read the repository's settings are enabled (${enabled.join(", ")}); they run only with a GitHub client (--settings on the command line)`);
|
|
3187
|
+
return runnable;
|
|
1737
3188
|
}
|
|
1738
3189
|
const disabled = requested.filter((name) => !enabled.includes(name));
|
|
1739
3190
|
if (disabled.length > 0) throw new ConformanceError(`${disabled.join(", ")} ${disabled.length === 1 ? "is" : "are"} not enabled in the 'conformance' section`);
|
|
3191
|
+
const offline = requested.filter((name) => !online && registry[name].requiresGitHub);
|
|
3192
|
+
if (offline.length > 0) throw new ConformanceError(`${offline.join(", ")} read${offline.length === 1 ? "s" : ""} the repository's settings and need${offline.length === 1 ? "s" : ""} a GitHub client (--settings on the command line)`);
|
|
1740
3193
|
return checkNames.filter((name) => requested.includes(name));
|
|
1741
3194
|
}
|
|
1742
3195
|
/**
|
|
@@ -1775,7 +3228,7 @@ function readImported(imported, name) {
|
|
|
1775
3228
|
*/
|
|
1776
3229
|
async function runChecks(options) {
|
|
1777
3230
|
const config = await conformanceOf(options);
|
|
1778
|
-
const names = selected(config, options.checks);
|
|
3231
|
+
const names = selected(config, options.checks, options.github !== void 0);
|
|
1779
3232
|
const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
|
|
1780
3233
|
const imported = await runImportGraphChecks(options.cwd, layout, names, config.checks);
|
|
1781
3234
|
const results = [];
|
|
@@ -1785,7 +3238,8 @@ async function runChecks(options) {
|
|
|
1785
3238
|
cwd: options.cwd,
|
|
1786
3239
|
checks: config.checks,
|
|
1787
3240
|
layout,
|
|
1788
|
-
configFiles: options.configFiles
|
|
3241
|
+
configFiles: options.configFiles,
|
|
3242
|
+
github: options.github
|
|
1789
3243
|
}) : readImported(imported, name);
|
|
1790
3244
|
results.push({
|
|
1791
3245
|
check: name,
|
|
@@ -1801,13 +3255,14 @@ async function runChecks(options) {
|
|
|
1801
3255
|
}
|
|
1802
3256
|
//#endregion
|
|
1803
3257
|
//#region src/cli-command.ts
|
|
1804
|
-
const USAGE = `Usage: workspace-conformance check [--cwd <directory>] [--check <name>]... [--list]
|
|
3258
|
+
const USAGE = `Usage: workspace-conformance check [--cwd <directory>] [--check <name>]... [--list] [--settings]
|
|
1805
3259
|
|
|
1806
3260
|
Runs the conformance checks enabled in the 'conformance' section of exadev.config.ts (or exadev.conformance.config.ts).
|
|
1807
3261
|
|
|
1808
3262
|
--cwd <directory> Directory that holds the config files. Defaults to the current directory.
|
|
1809
3263
|
--check <name> Run only this check, which must be enabled. Repeatable.
|
|
1810
3264
|
--list Print the names of all checks and stop.
|
|
3265
|
+
--settings Also run the enabled settings-* checks, which read the repository's settings through the GitHub API with the token in GITHUB_TOKEN or GH_TOKEN. Offline otherwise.
|
|
1811
3266
|
--help, -h Show this message.
|
|
1812
3267
|
|
|
1813
3268
|
Exit status: ${String(EXIT_CODES.clean)} when nothing is found, ${String(EXIT_CODES.violations)} when a check finds a violation, ${String(EXIT_CODES.failed)} when the checks could not run.
|
|
@@ -1824,6 +3279,14 @@ function requestedChecks(names) {
|
|
|
1824
3279
|
return name;
|
|
1825
3280
|
});
|
|
1826
3281
|
}
|
|
3282
|
+
/**
|
|
3283
|
+
* The client the settings checks read through, authenticated by the first non-empty token of `GITHUB_TOKEN` and `GH_TOKEN`.
|
|
3284
|
+
*/
|
|
3285
|
+
function clientFromEnvironment() {
|
|
3286
|
+
const token = [process.env["GITHUB_TOKEN"], process.env["GH_TOKEN"]].find((value) => value !== void 0 && value !== "");
|
|
3287
|
+
if (token === void 0) throw new ConformanceError("--settings needs a token in GITHUB_TOKEN or GH_TOKEN");
|
|
3288
|
+
return createGitHubClient({ token });
|
|
3289
|
+
}
|
|
1827
3290
|
async function runCheck(args, output) {
|
|
1828
3291
|
const { values } = parseArgs({
|
|
1829
3292
|
args: [...args],
|
|
@@ -1834,6 +3297,7 @@ async function runCheck(args, output) {
|
|
|
1834
3297
|
multiple: true
|
|
1835
3298
|
},
|
|
1836
3299
|
list: { type: "boolean" },
|
|
3300
|
+
settings: { type: "boolean" },
|
|
1837
3301
|
help: {
|
|
1838
3302
|
type: "boolean",
|
|
1839
3303
|
short: "h"
|
|
@@ -1852,7 +3316,8 @@ async function runCheck(args, output) {
|
|
|
1852
3316
|
}
|
|
1853
3317
|
const result = await runChecks({
|
|
1854
3318
|
cwd: resolve(values.cwd ?? process.cwd()),
|
|
1855
|
-
...requested === void 0 ? {} : { checks: requested }
|
|
3319
|
+
...requested === void 0 ? {} : { checks: requested },
|
|
3320
|
+
...values.settings === true ? { github: clientFromEnvironment() } : {}
|
|
1856
3321
|
});
|
|
1857
3322
|
for (const violation of result.violations) output.stderr(formatViolation(violation));
|
|
1858
3323
|
if (result.violations.length === 0) output.stdout(`no violations from ${result.results.map((entry) => entry.check).join(", ")}\n`);
|