workspace-conformance 2.0.2 → 3.0.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 +12 -11
- package/dist/cli.js +327 -76
- package/dist/index.cjs +328 -76
- package/dist/index.d.cts +51 -12
- package/dist/index.d.ts +51 -12
- package/dist/index.js +328 -77
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -129,7 +129,9 @@ function createGitHubClient(options) {
|
|
|
129
129
|
pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
|
|
130
130
|
requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
|
|
131
131
|
allowedMergeMethods: rule.parameters?.allowed_merge_methods
|
|
132
|
-
}))
|
|
132
|
+
})),
|
|
133
|
+
requiredLinearHistory: rules.some((rule) => rule.type === "required_linear_history"),
|
|
134
|
+
mergeQueue: rules.some((rule) => rule.type === "merge_queue")
|
|
133
135
|
};
|
|
134
136
|
}
|
|
135
137
|
};
|
|
@@ -1058,6 +1060,41 @@ async function readWorkspacePackages(cwd, layout) {
|
|
|
1058
1060
|
if (discovered.length === 0) throw new ConformanceError(`no workspace package found: ${patterns.length === 0 ? "the package patterns are empty, so no directory is selected" : `no directory the patterns ${patterns.join(", ")} select holds a package.json`}; for a single-package repository, set the layout's packages to ['.']`);
|
|
1059
1061
|
return classifyPackages(discovered, layout);
|
|
1060
1062
|
}
|
|
1063
|
+
/**
|
|
1064
|
+
* The `package.json` fields a package's declared dependencies are read from when the layout's `dependencyFields` is omitted, the default of the ESLint workspace rules that read the same layout.
|
|
1065
|
+
*/
|
|
1066
|
+
const DEFAULT_DEPENDENCY_FIELDS = ["dependencies"];
|
|
1067
|
+
/**
|
|
1068
|
+
* The fields npm and pnpm install dependencies from, read alongside the layout's own so that a dependency declared outside them can be told apart from one not declared at all.
|
|
1069
|
+
*/
|
|
1070
|
+
const MANIFEST_DEPENDENCY_FIELDS = [
|
|
1071
|
+
"dependencies",
|
|
1072
|
+
"devDependencies",
|
|
1073
|
+
"peerDependencies",
|
|
1074
|
+
"optionalDependencies"
|
|
1075
|
+
];
|
|
1076
|
+
/**
|
|
1077
|
+
* For each package directory, the fields its `package.json` declares each dependency in: the manifest dependency fields and any other the layout's `dependencyFields` names.
|
|
1078
|
+
*/
|
|
1079
|
+
async function readDeclaredDependencies(root, packages, layout) {
|
|
1080
|
+
const fields = [.../* @__PURE__ */ new Set([...MANIFEST_DEPENDENCY_FIELDS, ...layout.dependencyFields ?? DEFAULT_DEPENDENCY_FIELDS])];
|
|
1081
|
+
return new Map(await Promise.all(packages.map(async (member) => {
|
|
1082
|
+
const manifest = join(root, member.dir, "package.json");
|
|
1083
|
+
let content;
|
|
1084
|
+
try {
|
|
1085
|
+
content = JSON.parse(await readFile(manifest, "utf8"));
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
|
|
1088
|
+
}
|
|
1089
|
+
if (!isRecord(content)) throw new ConformanceError(`${manifest} is not a JSON object`);
|
|
1090
|
+
const declared = /* @__PURE__ */ new Map();
|
|
1091
|
+
for (const field of fields) {
|
|
1092
|
+
const entries = content[field];
|
|
1093
|
+
for (const name of isRecord(entries) ? Object.keys(entries) : []) declared.set(name, [...declared.get(name) ?? [], field]);
|
|
1094
|
+
}
|
|
1095
|
+
return [member.dir, declared];
|
|
1096
|
+
})));
|
|
1097
|
+
}
|
|
1061
1098
|
//#endregion
|
|
1062
1099
|
//#region src/checks/engines-floor.ts
|
|
1063
1100
|
/**
|
|
@@ -1317,7 +1354,15 @@ const METHODS = [
|
|
|
1317
1354
|
"merge"
|
|
1318
1355
|
];
|
|
1319
1356
|
/**
|
|
1320
|
-
*
|
|
1357
|
+
* How a message names each method, in the words GitHub's settings page uses.
|
|
1358
|
+
*/
|
|
1359
|
+
const METHOD_NAMES = {
|
|
1360
|
+
rebase: "rebase merging",
|
|
1361
|
+
squash: "squash merging",
|
|
1362
|
+
merge: "merge commits"
|
|
1363
|
+
};
|
|
1364
|
+
/**
|
|
1365
|
+
* 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, no `pull_request` rule that applies to the branch leaves it out of its allowed methods, and, for a merge commit, no `required_linear_history` rule applies, since that rule keeps merge commits off the branch.
|
|
1321
1366
|
*
|
|
1322
1367
|
* When the repository's plan has no rulesets, no rule applies to the branch and the repository settings alone decide.
|
|
1323
1368
|
*
|
|
@@ -1335,15 +1380,17 @@ const settingsMergeMethods = async ({ cwd, options, github }) => {
|
|
|
1335
1380
|
squash: settings.allowSquashMerge,
|
|
1336
1381
|
merge: settings.allowMergeCommit
|
|
1337
1382
|
};
|
|
1338
|
-
const
|
|
1383
|
+
const requiredLinearHistory = "unavailable" in read ? false : read.rules.requiredLinearHistory;
|
|
1384
|
+
const rulesAllow = (method) => !(method === "merge" && requiredLinearHistory) && pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method));
|
|
1385
|
+
const available = METHODS.filter((method) => repositoryAllows[method] && rulesAllow(method));
|
|
1339
1386
|
const violations = available.filter((method) => method !== wanted).map((method) => ({
|
|
1340
1387
|
code: "settings-merge-methods/method-enabled",
|
|
1341
|
-
message: `${file} allows ${method}
|
|
1388
|
+
message: `${file} allows ${METHOD_NAMES[method]}; only ${METHOD_NAMES[wanted]} should be allowed`,
|
|
1342
1389
|
file
|
|
1343
1390
|
}));
|
|
1344
1391
|
if (!available.includes(wanted)) violations.push({
|
|
1345
1392
|
code: "settings-merge-methods/method-unavailable",
|
|
1346
|
-
message: `${file} does not allow ${wanted}
|
|
1393
|
+
message: `${file} does not allow ${METHOD_NAMES[wanted]}`,
|
|
1347
1394
|
file
|
|
1348
1395
|
});
|
|
1349
1396
|
return violations;
|
|
@@ -1536,6 +1583,7 @@ function parseWorkflow(file, source) {
|
|
|
1536
1583
|
const triggers = triggersOf(reader, root["on"]);
|
|
1537
1584
|
const call = triggers.get("workflow_call");
|
|
1538
1585
|
const outputs = reader.record(reader.record(call, "on.workflow_call")["outputs"], "on.workflow_call.outputs");
|
|
1586
|
+
const inputs = reader.record(reader.record(call, "on.workflow_call")["inputs"], "on.workflow_call.inputs");
|
|
1539
1587
|
return {
|
|
1540
1588
|
file,
|
|
1541
1589
|
name: reader.text(root["name"], "name"),
|
|
@@ -1545,7 +1593,8 @@ function parseWorkflow(file, source) {
|
|
|
1545
1593
|
permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
|
|
1546
1594
|
env: reader.texts(root["env"], "env"),
|
|
1547
1595
|
jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
|
|
1548
|
-
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`) ?? ""]))
|
|
1596
|
+
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`) ?? ""])),
|
|
1597
|
+
callInputs: Object.fromEntries(Object.entries(inputs).map(([name, input]) => [name, reader.text(reader.record(input, `on.workflow_call.inputs.${name}`)["default"], `on.workflow_call.inputs.${name}.default`)]))
|
|
1549
1598
|
};
|
|
1550
1599
|
}
|
|
1551
1600
|
/**
|
|
@@ -1877,6 +1926,15 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
|
|
|
1877
1926
|
* Commands that publish a package to a registry.
|
|
1878
1927
|
*/
|
|
1879
1928
|
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");
|
|
1929
|
+
/**
|
|
1930
|
+
* npm reads no token variable by name: a variable reaches it only through `${NAME}` (or `${NAME?}`) in the value of an `.npmrc` setting. These are the settings that carry credentials.
|
|
1931
|
+
*/
|
|
1932
|
+
const NPMRC_AUTH_SETTING = /^\s*[^#;=\s]*(?:_authToken|_auth|_password)\s*=(.*)$/u;
|
|
1933
|
+
const NPMRC_VARIABLE = /\$\{([^${}?]+)\??\}/gu;
|
|
1934
|
+
/**
|
|
1935
|
+
* `semantic-release` as the command a publish line runs, whose npm plugin reads `NPM_TOKEN` itself.
|
|
1936
|
+
*/
|
|
1937
|
+
const SEMANTIC_RELEASE = /(?:^|\s)semantic-release(?:\s|$)/u;
|
|
1880
1938
|
const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
|
|
1881
1939
|
/**
|
|
1882
1940
|
* Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
|
|
@@ -1888,6 +1946,34 @@ async function requestsProvenance(cwd) {
|
|
|
1888
1946
|
const config = isRecord(manifest) ? manifest["publishConfig"] : void 0;
|
|
1889
1947
|
return isRecord(config) && config["provenance"] === true;
|
|
1890
1948
|
}
|
|
1949
|
+
/**
|
|
1950
|
+
* The variables the auth settings of the `.npmrc` in the working directory read, which npm and pnpm load for a publish run there.
|
|
1951
|
+
*/
|
|
1952
|
+
async function npmrcTokenVariables(cwd) {
|
|
1953
|
+
const file = resolve(cwd, ".npmrc");
|
|
1954
|
+
if (!existsSync(file)) return [];
|
|
1955
|
+
const values = (await readFile(file, "utf8")).split("\n").flatMap((line) => NPMRC_AUTH_SETTING.exec(line)?.[1] ?? []);
|
|
1956
|
+
return [...new Set(values.flatMap((value) => [...value.matchAll(NPMRC_VARIABLE)].flatMap((match) => match[1] ?? [])))];
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* The token variables that what a publish step runs reads, with what reads each: the `.npmrc` that an earlier `actions/setup-node` step with `registry-url` writes reads `NODE_AUTH_TOKEN`, semantic-release's npm plugin reads `NPM_TOKEN`, and the `.npmrc` of the working directory reads the variables of its auth settings.
|
|
1960
|
+
*/
|
|
1961
|
+
function tokenReads(job, step, publishes, npmrcVariables) {
|
|
1962
|
+
const reads = [];
|
|
1963
|
+
if (job.steps.some((earlier) => earlier.index < step.index && stepUses(earlier, "actions/setup-node") && (earlier.with["registry-url"] ?? "") !== "")) reads.push({
|
|
1964
|
+
variable: "NODE_AUTH_TOKEN",
|
|
1965
|
+
reader: "the .npmrc actions/setup-node writes for registry-url"
|
|
1966
|
+
});
|
|
1967
|
+
if (publishes.some((line) => SEMANTIC_RELEASE.test(line))) reads.push({
|
|
1968
|
+
variable: "NPM_TOKEN",
|
|
1969
|
+
reader: "semantic-release's npm plugin"
|
|
1970
|
+
});
|
|
1971
|
+
reads.push(...npmrcVariables.map((variable) => ({
|
|
1972
|
+
variable,
|
|
1973
|
+
reader: "the .npmrc of the working directory"
|
|
1974
|
+
})));
|
|
1975
|
+
return reads.filter((read, index) => reads.findIndex((other) => other.variable === read.variable) === index);
|
|
1976
|
+
}
|
|
1891
1977
|
function attestationViolations(workflow, job) {
|
|
1892
1978
|
const permissions = effectivePermissions(workflow, job);
|
|
1893
1979
|
return job.steps.filter((step) => ATTESTATION_ACTIONS.some((action) => stepUses(step, action))).flatMap((step) => {
|
|
@@ -1899,18 +1985,18 @@ function attestationViolations(workflow, job) {
|
|
|
1899
1985
|
/**
|
|
1900
1986
|
* 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.
|
|
1901
1987
|
*/
|
|
1902
|
-
function passesToken(workflow, job) {
|
|
1903
|
-
return [void 0, ...job.steps].some((step) =>
|
|
1988
|
+
function passesToken(workflow, job, variables) {
|
|
1989
|
+
return [void 0, ...job.steps].some((step) => variables.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
|
|
1904
1990
|
}
|
|
1905
|
-
function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
1906
|
-
if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
|
|
1991
|
+
function tokenlessPublishViolations(workflow, job, provenanceInManifest, npmrcVariables) {
|
|
1992
|
+
if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job, [...REGISTRY_TOKEN_VARIABLES, ...npmrcVariables])) return [];
|
|
1907
1993
|
return job.steps.flatMap((step) => {
|
|
1908
1994
|
const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
|
|
1909
1995
|
if (publishes.length === 0) return [];
|
|
1910
1996
|
const env = effectiveEnv(workflow, job, step);
|
|
1911
|
-
const
|
|
1997
|
+
const unprotected = tokenReads(job, step, publishes, npmrcVariables).filter((read) => env[read.variable] !== "");
|
|
1912
1998
|
const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
|
|
1913
|
-
return
|
|
1999
|
+
return unprotected.length === 0 || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${unprotected.map((read) => `${read.variable} (read by ${read.reader})`).join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set ${unprotected.length === 1 ? "it" : "them"} to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
|
|
1914
2000
|
});
|
|
1915
2001
|
}
|
|
1916
2002
|
/**
|
|
@@ -1918,45 +2004,19 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
|
1918
2004
|
*
|
|
1919
2005
|
* `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.
|
|
1920
2006
|
*
|
|
1921
|
-
* `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
|
|
2007
|
+
* `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 (set to `''` at step, job or workflow level) every token variable the publish reads, 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.
|
|
1922
2008
|
*
|
|
1923
|
-
*
|
|
2009
|
+
* A variable is judged only where something reads it, since npm reads none by name, only through `${NAME}` in an `.npmrc`: `NODE_AUTH_TOKEN` after an `actions/setup-node` step with `registry-url`, whose `.npmrc` refers to it; `NPM_TOKEN` for `semantic-release`, whose npm plugin reads it; and the variables the auth settings (`_authToken`, `_auth`, `_password`) of the `.npmrc` in the working directory refer to.
|
|
2010
|
+
*
|
|
2011
|
+
* Limits: `registry-url` is not reported, because it does not stop the exchange (see the README); an `.npmrc` the file does not show, such as one in the runner's home directory, a Yarn `.yarnrc.yml` and a token set through an `npm_config_` variable are not read; 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.
|
|
1924
2012
|
*/
|
|
1925
2013
|
const workflowCredentials = async ({ cwd, options }) => {
|
|
1926
2014
|
const provenanceInManifest = await requestsProvenance(cwd);
|
|
1927
|
-
|
|
2015
|
+
const npmrcVariables = await npmrcTokenVariables(cwd);
|
|
2016
|
+
return (await loadWorkflows(cwd, options)).flatMap((workflow) => workflow.jobs.flatMap((job) => [...attestationViolations(workflow, job), ...tokenlessPublishViolations(workflow, job, provenanceInManifest, npmrcVariables)]));
|
|
1928
2017
|
};
|
|
1929
2018
|
//#endregion
|
|
1930
|
-
//#region src/
|
|
1931
|
-
const DEFAULT_DEPLOY_JOBS = ["deploy"];
|
|
1932
|
-
const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
|
|
1933
|
-
const DEFAULT_BRANCH = "main";
|
|
1934
|
-
/**
|
|
1935
|
-
* The expression that always names the repository's default branch.
|
|
1936
|
-
*/
|
|
1937
|
-
const DEFAULT_BRANCH_EXPRESSION$1 = /^\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}$/u;
|
|
1938
|
-
/**
|
|
1939
|
-
* The text a junction job decides on: its `if`, and the conditions and scripts of its steps.
|
|
1940
|
-
*/
|
|
1941
|
-
function decisionText(job) {
|
|
1942
|
-
return [job.condition, ...job.steps.flatMap((step) => [
|
|
1943
|
-
step.condition,
|
|
1944
|
-
step.run,
|
|
1945
|
-
...Object.values(step.env),
|
|
1946
|
-
...Object.values(step.with)
|
|
1947
|
-
])].filter((text) => text !== void 0).join("\n");
|
|
1948
|
-
}
|
|
1949
|
-
/**
|
|
1950
|
-
* 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`.
|
|
1951
|
-
*/
|
|
1952
|
-
const RESULT_SOURCE = /needs\.(?:\*|[\w-]+)\.result|toJSON\(\s*needs\s*\)/u;
|
|
1953
|
-
const FAILURE_WORD = /['"]failure['"]/u;
|
|
1954
|
-
const CANCELLED_WORD = /['"]cancelled['"]/u;
|
|
1955
|
-
const SKIPPED_OR_NOT_SUCCESS = /needs\.(?:\*|[\w-]+)\.result[^\n]*(?:['"]skipped['"]|!==?\s*['"]success['"])|!\s*contains\(\s*needs\.\*\.result\s*,\s*['"]success['"]/u;
|
|
1956
|
-
/**
|
|
1957
|
-
* An `if` that compares the ref or the event name.
|
|
1958
|
-
*/
|
|
1959
|
-
const COMPARES_REF_OR_EVENT = /github\.(?:ref|ref_name|event_name)\s*==/u;
|
|
2019
|
+
//#region src/workflows/expressions.ts
|
|
1960
2020
|
/**
|
|
1961
2021
|
* An expression written inside the `${{ }}` that marks it as one.
|
|
1962
2022
|
*/
|
|
@@ -2004,6 +2064,153 @@ function unwrapped(expression) {
|
|
|
2004
2064
|
return enclosedByOneGroup(inner) ? unwrapped(inner.slice(1, -1)) : inner;
|
|
2005
2065
|
}
|
|
2006
2066
|
/**
|
|
2067
|
+
* A string literal of an expression, whose body writes a quote as `''`.
|
|
2068
|
+
*/
|
|
2069
|
+
const STRING_LITERAL = /^'((?:[^']|'')*)'$/u;
|
|
2070
|
+
/**
|
|
2071
|
+
* A call of `format`, whose name, like every function name in an expression, is case-insensitive.
|
|
2072
|
+
*/
|
|
2073
|
+
const FORMAT_CALL = /^format\s*\(([\s\S]*)\)$/iu;
|
|
2074
|
+
/**
|
|
2075
|
+
* A reference to an input of the workflow.
|
|
2076
|
+
*/
|
|
2077
|
+
const INPUT_REFERENCE = /^inputs\.([\w-]+)$/u;
|
|
2078
|
+
/**
|
|
2079
|
+
* A placeholder of a `format` pattern, or the doubled brace that writes a literal one.
|
|
2080
|
+
*/
|
|
2081
|
+
const FORMAT_PLACEHOLDER = /\{\{|\}\}|\{(\d+)\}/gu;
|
|
2082
|
+
/**
|
|
2083
|
+
* The arguments of a call, split at the commas outside quotes and parentheses.
|
|
2084
|
+
*/
|
|
2085
|
+
function callArguments(text) {
|
|
2086
|
+
const parts = [];
|
|
2087
|
+
let depth = 0;
|
|
2088
|
+
let quoted = false;
|
|
2089
|
+
let start = 0;
|
|
2090
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
2091
|
+
const character = text.charAt(index);
|
|
2092
|
+
if (character === "'") quoted = !quoted;
|
|
2093
|
+
else if (!quoted && character === "(") depth += 1;
|
|
2094
|
+
else if (!quoted && character === ")") depth -= 1;
|
|
2095
|
+
else if (!quoted && depth === 0 && character === ",") {
|
|
2096
|
+
parts.push(text.slice(start, index));
|
|
2097
|
+
start = index + 1;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
parts.push(text.slice(start));
|
|
2101
|
+
return parts.map((part) => part.trim());
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* `format(pattern, ...values)` as GitHub evaluates it: `{N}` is the value at index N and `{{` and `}}` are literal braces. `undefined` when a placeholder names a value that is not given, which GitHub rejects.
|
|
2105
|
+
*/
|
|
2106
|
+
function formatted(pattern, values) {
|
|
2107
|
+
let text = "";
|
|
2108
|
+
let end = 0;
|
|
2109
|
+
for (const match of pattern.matchAll(FORMAT_PLACEHOLDER)) {
|
|
2110
|
+
const index = match[1];
|
|
2111
|
+
const value = index === void 0 ? match[0].charAt(0) : values[Number(index)];
|
|
2112
|
+
if (value === void 0) return;
|
|
2113
|
+
text += `${pattern.slice(end, match.index)}${value}`;
|
|
2114
|
+
end = match.index + match[0].length;
|
|
2115
|
+
}
|
|
2116
|
+
return `${text}${pattern.slice(end)}`;
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* The value an operand of an expression has on every run, or `undefined` when that depends on the run: a string literal, an input whose value `inputs` gives, or `format()` of such values. An input `inputs` holds as `undefined`, or does not hold, is not known.
|
|
2120
|
+
*/
|
|
2121
|
+
function constantString(operand, inputs) {
|
|
2122
|
+
const text = unwrapped(operand);
|
|
2123
|
+
const literal = STRING_LITERAL.exec(text)?.[1];
|
|
2124
|
+
if (literal !== void 0) return literal.replaceAll("''", "'");
|
|
2125
|
+
const input = INPUT_REFERENCE.exec(text)?.[1];
|
|
2126
|
+
if (input !== void 0) return Object.hasOwn(inputs, input) ? inputs[input] : void 0;
|
|
2127
|
+
const call = FORMAT_CALL.exec(text)?.[1];
|
|
2128
|
+
if (call === void 0) return;
|
|
2129
|
+
const [pattern, ...rest] = callArguments(call).map((argument) => constantString(argument, inputs));
|
|
2130
|
+
const values = rest.filter((value) => value !== void 0);
|
|
2131
|
+
return pattern === void 0 || values.length !== rest.length ? void 0 : formatted(pattern, values);
|
|
2132
|
+
}
|
|
2133
|
+
//#endregion
|
|
2134
|
+
//#region src/checks/workflow-job-ordering.ts
|
|
2135
|
+
const DEFAULT_DEPLOY_JOBS = ["deploy"];
|
|
2136
|
+
const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
|
|
2137
|
+
const DEFAULT_BRANCH = "main";
|
|
2138
|
+
/**
|
|
2139
|
+
* The expression that always names the repository's default branch.
|
|
2140
|
+
*/
|
|
2141
|
+
const DEFAULT_BRANCH_EXPRESSION$1 = /^\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}$/u;
|
|
2142
|
+
/**
|
|
2143
|
+
* The whole of an environment value that is one expression, `${{ <expression> }}`.
|
|
2144
|
+
*/
|
|
2145
|
+
const WHOLE_EXPRESSION = /^\$\{\{\s*([\s\S]*?)\s*\}\}$/u;
|
|
2146
|
+
/**
|
|
2147
|
+
* A reference to an environment variable in a script, `$NAME` or `${NAME}`, and in an expression, `env.NAME`.
|
|
2148
|
+
*/
|
|
2149
|
+
const SCRIPT_VARIABLE = /\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/gu;
|
|
2150
|
+
const EXPRESSION_VARIABLE = /\benv\.([A-Za-z_]\w*)/gu;
|
|
2151
|
+
/**
|
|
2152
|
+
* A script or condition with each environment variable whose value is a whole expression replaced by that expression, so `"$RESULT" != "success"` with `RESULT: ${{ needs.test.result }}` reads as the test of `needs.test.result` it is.
|
|
2153
|
+
*/
|
|
2154
|
+
function withVariablesResolved(text, env, reference) {
|
|
2155
|
+
return text.replaceAll(reference, (match, ...names) => {
|
|
2156
|
+
const name = names.find((candidate) => typeof candidate === "string");
|
|
2157
|
+
const value = name === void 0 ? void 0 : env[name];
|
|
2158
|
+
return (value === void 0 ? void 0 : WHOLE_EXPRESSION.exec(value.trim())?.[1]) ?? match;
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* The text a junction job decides on: its `if`, and the conditions, scripts and inputs of its steps, with the environment variables they read replaced by the expressions that set them, and the values of their environments.
|
|
2163
|
+
*/
|
|
2164
|
+
function decisionText(workflow, job) {
|
|
2165
|
+
return [job.condition === void 0 ? void 0 : withVariablesResolved(job.condition, effectiveEnv(workflow, job, void 0), EXPRESSION_VARIABLE), ...job.steps.flatMap((step) => {
|
|
2166
|
+
const env = effectiveEnv(workflow, job, step);
|
|
2167
|
+
return [
|
|
2168
|
+
step.condition === void 0 ? void 0 : withVariablesResolved(step.condition, env, EXPRESSION_VARIABLE),
|
|
2169
|
+
step.run === void 0 ? void 0 : withVariablesResolved(step.run, env, SCRIPT_VARIABLE),
|
|
2170
|
+
...Object.values(env),
|
|
2171
|
+
...Object.values(step.with)
|
|
2172
|
+
];
|
|
2173
|
+
})].filter((text) => text !== void 0).join("\n");
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* 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`.
|
|
2177
|
+
*/
|
|
2178
|
+
const RESULT_SOURCE = /needs\.(?:\*|[\w-]+)\.result|toJSON\(\s*needs\s*\)/u;
|
|
2179
|
+
const FAILURE_WORD = /['"]failure['"]/u;
|
|
2180
|
+
const CANCELLED_WORD = /['"]cancelled['"]/u;
|
|
2181
|
+
/**
|
|
2182
|
+
* A test that a result is not `success`, which fails on `failure` and `cancelled`, and on `skipped` too. The group is the job, or `*`.
|
|
2183
|
+
*/
|
|
2184
|
+
const NOT_SUCCESS = /needs\.(\*|[\w-]+)\.result[^\n]*?!==?\s*['"]success['"]/gu;
|
|
2185
|
+
/**
|
|
2186
|
+
* A test that names `skipped`, or that fails unless some result is `success`.
|
|
2187
|
+
*/
|
|
2188
|
+
const SKIPPED_TEST = /needs\.(?:\*|[\w-]+)\.result[^\n]*['"]skipped['"]|!\s*contains\(\s*needs\.\*\.result\s*,\s*['"]success['"]/u;
|
|
2189
|
+
/**
|
|
2190
|
+
* A read of a job's output, which is what an `if` that skips a job on the result of an earlier one reads.
|
|
2191
|
+
*/
|
|
2192
|
+
const OUTPUT_READ = /needs\.[\w-]+\.outputs\.[\w-]+/gu;
|
|
2193
|
+
/**
|
|
2194
|
+
* Whether an `if` can skip the job, its own or that of a job it needs, without the junction job seeing why: a job runs unless an `if` other than `always()` stops it, and a skip that comes only from a need that failed is a failure the junction rightly fails on. A skip is seen when the junction reads every job output those conditions read, as a junction that tests `has-packages` before the result of a job skipped on it does.
|
|
2195
|
+
*/
|
|
2196
|
+
function skippableUnseen(workflow, jobId, text) {
|
|
2197
|
+
return workflow.jobs.filter((job) => job.id === jobId || transitiveNeeds(workflow, jobId).has(job.id)).flatMap((job) => job.condition === void 0 || callsAlways(job.condition) ? [] : [job.condition]).some((condition) => {
|
|
2198
|
+
const reads = [...condition.matchAll(OUTPUT_READ)].map((read) => read[0]);
|
|
2199
|
+
return reads.length === 0 || !reads.every((read) => text.includes(read));
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Whether the junction fails when a job it needs is skipped: it names `skipped`, or tests a result for not being `success` where an `if` can skip that job (any of its needs, for `needs.*.result`) unseen.
|
|
2204
|
+
*/
|
|
2205
|
+
function failsOnSkipped(workflow, junction, text) {
|
|
2206
|
+
const tested = [...text.matchAll(NOT_SUCCESS)].flatMap((match) => match[1] === "*" ? junction.needs : match[1] ?? []);
|
|
2207
|
+
return SKIPPED_TEST.test(text) || tested.some((id) => skippableUnseen(workflow, id, text));
|
|
2208
|
+
}
|
|
2209
|
+
/**
|
|
2210
|
+
* An `if` that compares the ref or the event name.
|
|
2211
|
+
*/
|
|
2212
|
+
const COMPARES_REF_OR_EVENT = /github\.(?:ref|ref_name|event_name)\s*==/u;
|
|
2213
|
+
/**
|
|
2007
2214
|
* 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`.
|
|
2008
2215
|
*/
|
|
2009
2216
|
function keepsOutOfPullRequests(expression) {
|
|
@@ -2050,17 +2257,17 @@ function junctionOrdering(workflow, options) {
|
|
|
2050
2257
|
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));
|
|
2051
2258
|
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));
|
|
2052
2259
|
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));
|
|
2053
|
-
const text = decisionText(junction);
|
|
2260
|
+
const text = decisionText(workflow, junction);
|
|
2054
2261
|
const reportsOutcome = junction.steps.some((step) => stepUses(step, OUTCOME_ACTION));
|
|
2055
|
-
if (
|
|
2056
|
-
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));
|
|
2262
|
+
if (failsOnSkipped(workflow, junction, 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));
|
|
2263
|
+
if (!reportsOutcome && !(RESULT_SOURCE.test(text) && (FAILURE_WORD.test(text) && CANCELLED_WORD.test(text) || [...text.matchAll(NOT_SUCCESS)].length > 0))) 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));
|
|
2057
2264
|
}
|
|
2058
2265
|
return violations;
|
|
2059
2266
|
}
|
|
2060
2267
|
/**
|
|
2061
2268
|
* 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`.
|
|
2062
2269
|
*
|
|
2063
|
-
* 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.
|
|
2270
|
+
* 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, with the environment variables its scripts and conditions read replaced by the expressions that set them: it must read the results of its needs (`needs.*.result`, `needs.<job>.result` or `toJSON(needs)`) and name both `'failure'` and `'cancelled'` or test a result for `!= 'success'`, or use the `re-actors/alls-green` action; a result read in a way the text does not show is reported as incomplete. A `!= 'success'` test fails on a skip too, which is reported only for a job an `if` can skip without the junction reading what that `if` reads.
|
|
2064
2271
|
*/
|
|
2065
2272
|
const workflowJobOrdering = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => [...releaseAndDocsOrdering(workflow, options), ...junctionOrdering(workflow, options)]);
|
|
2066
2273
|
//#endregion
|
|
@@ -2068,12 +2275,21 @@ const workflowJobOrdering = async ({ cwd, options }) => (await loadWorkflows(cwd
|
|
|
2068
2275
|
/**
|
|
2069
2276
|
* 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.
|
|
2070
2277
|
*
|
|
2071
|
-
* 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.
|
|
2278
|
+
* 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.
|
|
2279
|
+
*
|
|
2280
|
+
* Whether the branch has a merge queue is a setting, not something a file shows. Given a GitHub client, the check reads the rules of the branch (`branch`, else the default branch, of `repository`, else the `origin` remote) once it has found a workflow without the trigger, and reports only when a `merge_queue` rule applies, since without a queue no `merge_group` event is ever sent. Offline it cannot tell, so it reports every such workflow.
|
|
2281
|
+
*
|
|
2282
|
+
* 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; only rulesets are read, so a queue configured through classic branch protection is not seen and, with a client, nothing is reported for it.
|
|
2072
2283
|
*/
|
|
2073
|
-
const workflowMergeGroup = async ({ cwd, options }) =>
|
|
2074
|
-
const
|
|
2075
|
-
|
|
2076
|
-
|
|
2284
|
+
const workflowMergeGroup = async ({ cwd, options, github }) => {
|
|
2285
|
+
const violations = (await loadWorkflows(cwd, options)).flatMap((workflow) => {
|
|
2286
|
+
const location = workflow.triggerLocations.get("pull_request");
|
|
2287
|
+
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)];
|
|
2288
|
+
});
|
|
2289
|
+
if (violations.length === 0 || github === void 0) return violations;
|
|
2290
|
+
const slug = await repositoryOf(cwd, options);
|
|
2291
|
+
return (await github.branchRules(slug, await branchOf(github, slug, options))).mergeQueue ? violations : [];
|
|
2292
|
+
};
|
|
2077
2293
|
//#endregion
|
|
2078
2294
|
//#region src/checks/workflow-release-job.ts
|
|
2079
2295
|
const CHECKOUT_ACTION$1 = "actions/checkout";
|
|
@@ -2268,7 +2484,7 @@ const workflowRepositoryDispatch = async ({ cwd, options }) => (await loadWorkfl
|
|
|
2268
2484
|
//#endregion
|
|
2269
2485
|
//#region src/checks/workflow-runner-resolution.ts
|
|
2270
2486
|
/**
|
|
2271
|
-
* GitHub's own images
|
|
2487
|
+
* The `hostedLabels` used when the options set none: GitHub's own images, `ubuntu`, `windows` and `macos`, as `-latest`, a version, or a version with an architecture or size suffix, and `ubuntu-slim`. Setting `hostedLabels` replaces it, so a workflow that also falls back to a third-party hosted runner extends it: `[...DEFAULT_HOSTED_LABELS, '^blacksmith-']`.
|
|
2272
2488
|
*/
|
|
2273
2489
|
const DEFAULT_HOSTED_LABELS = ["^(?:ubuntu|windows|macos)-(?:latest|slim|[0-9][0-9.]*)(?:-(?:arm|intel|xlarge|large))*$"];
|
|
2274
2490
|
/**
|
|
@@ -2280,10 +2496,6 @@ const RESOLVED_RUNNER = /fromJSON\(\s*needs\.([\w-]+)\.outputs\.([\w-]+)\s*\)/gi
|
|
|
2280
2496
|
*/
|
|
2281
2497
|
const JOB_OUTPUT_REFERENCE = /^\$\{\{\s*jobs\.([\w-]+)\.outputs\.([\w-]+)\s*\}\}$/u;
|
|
2282
2498
|
/**
|
|
2283
|
-
* A fallback in an output expression: `||` followed by a string literal, whose body may hold `''` for a quote.
|
|
2284
|
-
*/
|
|
2285
|
-
const LITERAL_FALLBACK = /\|\|\s*'((?:[^']|'')*)'/gu;
|
|
2286
|
-
/**
|
|
2287
2499
|
* The resolver behind each `fromJson(needs.<job>.outputs.<name>)` in a `runs-on` of the workflow; none for one this check cannot read.
|
|
2288
2500
|
*/
|
|
2289
2501
|
function resolversOf(workflow, workflows) {
|
|
@@ -2301,7 +2513,8 @@ function implementation(workflow, workflows, caller, output) {
|
|
|
2301
2513
|
if (caller.uses === void 0) return {
|
|
2302
2514
|
workflow,
|
|
2303
2515
|
job: caller,
|
|
2304
|
-
output
|
|
2516
|
+
output,
|
|
2517
|
+
inputs: {}
|
|
2305
2518
|
};
|
|
2306
2519
|
const reference = parseUses(caller.uses);
|
|
2307
2520
|
if (reference.kind !== "local") return;
|
|
@@ -2310,17 +2523,22 @@ function implementation(workflow, workflows, caller, output) {
|
|
|
2310
2523
|
const forwarded = JOB_OUTPUT_REFERENCE.exec(called.callOutputs[output] ?? "");
|
|
2311
2524
|
const job = called.jobs.find((candidate) => candidate.id === forwarded?.[1]);
|
|
2312
2525
|
const jobOutput = forwarded?.[2];
|
|
2526
|
+
const inputs = Object.fromEntries(Object.entries(called.callInputs).map(([name, fallback]) => {
|
|
2527
|
+
const passed = caller.with[name];
|
|
2528
|
+
return [name, passed === void 0 ? fallback : hasExpression(passed) ? void 0 : passed];
|
|
2529
|
+
}));
|
|
2313
2530
|
return job === void 0 || jobOutput === void 0 ? void 0 : {
|
|
2314
2531
|
workflow: called,
|
|
2315
2532
|
job,
|
|
2316
|
-
output: jobOutput
|
|
2533
|
+
output: jobOutput,
|
|
2534
|
+
inputs
|
|
2317
2535
|
};
|
|
2318
2536
|
}
|
|
2319
2537
|
/**
|
|
2320
|
-
* The runner labels the
|
|
2538
|
+
* The runner labels the fallback of an output expression names, read as the JSON the `fromJson` that consumes it expects: a label or a list of labels. The fallback is the first operand after the first of the expression's top-level `||` chain whose value is the same on every run and not empty (see `constantString`), which is what the chain yields when the operands before it are empty. `undefined` when there is no such operand, and an empty list when its value is not labels.
|
|
2321
2539
|
*/
|
|
2322
|
-
function fallbackLabels(expression) {
|
|
2323
|
-
const literal =
|
|
2540
|
+
function fallbackLabels(expression, inputs) {
|
|
2541
|
+
const literal = operands(unwrapped(expression), "||").slice(1).map((operand) => constantString(operand, inputs)).find((value) => value !== void 0 && value !== "");
|
|
2324
2542
|
if (literal === void 0) return;
|
|
2325
2543
|
let parsed;
|
|
2326
2544
|
try {
|
|
@@ -2335,9 +2553,13 @@ function fallbackLabels(expression) {
|
|
|
2335
2553
|
function resolverViolations(resolver, hosted) {
|
|
2336
2554
|
const violations = [];
|
|
2337
2555
|
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));
|
|
2338
|
-
const fallback = fallbackLabels(resolver.job.outputs[resolver.output] ?? "");
|
|
2339
|
-
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
|
|
2340
|
-
else if (fallback.length === 0
|
|
2556
|
+
const fallback = fallbackLabels(resolver.job.outputs[resolver.output] ?? "", resolver.inputs);
|
|
2557
|
+
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 fallback whose value is known before the run (\`|| '["<hosted label>"]'\`, or format() of literals and of inputs with a literal value), so the jobs that read it have no runner when resolution produces nothing`, resolver.job.location));
|
|
2558
|
+
else if (fallback.length === 0) 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}' is not a runner label or a non-empty list of them, so the jobs that read it have no runner when resolution produces nothing`, resolver.job.location));
|
|
2559
|
+
else {
|
|
2560
|
+
const unhosted = fallback.filter((label) => !hosted.some((pattern) => pattern.test(label)));
|
|
2561
|
+
if (unhosted.length > 0) 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}' names ${unhosted.map((label) => `'${label}'`).join(", ")}, which ${unhosted.length === 1 ? "matches" : "match"} no pattern of hostedLabels, so it is not known to offer a runner when resolution produces nothing; name a hosted label, or add the label's pattern to hostedLabels (extending DEFAULT_HOSTED_LABELS) if it is hosted, by GitHub or by a third party`, resolver.job.location));
|
|
2562
|
+
}
|
|
2341
2563
|
return violations;
|
|
2342
2564
|
}
|
|
2343
2565
|
function customLabels(job, hosted) {
|
|
@@ -2357,14 +2579,14 @@ function repeatedLabels(workflow, hosted) {
|
|
|
2357
2579
|
/**
|
|
2358
2580
|
* 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.
|
|
2359
2581
|
*
|
|
2360
|
-
* 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
|
|
2582
|
+
* 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's fallback is the first operand after the first of its expression's top-level `||` chain whose value is the same on every run: a string literal, or `format()` of literals and of inputs of the reusable workflow, read from the literal the caller passes or else the input's default. It 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, and an output with no such operand has no fallback.
|
|
2361
2583
|
*/
|
|
2362
2584
|
const workflowRunnerResolution = async ({ cwd, options }) => {
|
|
2363
2585
|
const hosted = (options.hostedLabels ?? DEFAULT_HOSTED_LABELS).map((source) => new RegExp(source, "u"));
|
|
2364
2586
|
const selected = await loadWorkflows(cwd, options);
|
|
2365
2587
|
const available = await loadWorkflows(cwd, { workflows: DEFAULT_WORKFLOWS });
|
|
2366
2588
|
const resolvers = /* @__PURE__ */ new Map();
|
|
2367
|
-
for (const workflow of selected) for (const resolver of resolversOf(workflow, available)) resolvers.set(`${resolver.workflow.file}#${resolver.job.id}#${resolver.output}`, resolver);
|
|
2589
|
+
for (const workflow of selected) for (const resolver of resolversOf(workflow, available)) resolvers.set(`${resolver.workflow.file}#${resolver.job.id}#${resolver.output}#${JSON.stringify(resolver.inputs)}`, resolver);
|
|
2368
2590
|
return [...selected.flatMap((workflow) => repeatedLabels(workflow, hosted)), ...[...resolvers.values()].flatMap((resolver) => resolverViolations(resolver, hosted))];
|
|
2369
2591
|
};
|
|
2370
2592
|
//#endregion
|
|
@@ -2812,10 +3034,17 @@ async function runImportChecks(input) {
|
|
|
2812
3034
|
const { cwd, layout, entries } = input;
|
|
2813
3035
|
const root = workspaceRoot(cwd, layout);
|
|
2814
3036
|
const packages = await readWorkspacePackages(cwd, layout);
|
|
3037
|
+
const declared = await readDeclaredDependencies(root, packages, layout);
|
|
3038
|
+
const read = new Set(layout.dependencyFields ?? DEFAULT_DEPENDENCY_FIELDS);
|
|
3039
|
+
const judged = (from, to) => {
|
|
3040
|
+
const fields = to.name === void 0 ? void 0 : declared.get(from.dir)?.get(to.name);
|
|
3041
|
+
return fields === void 0 || fields.some((field) => read.has(field));
|
|
3042
|
+
};
|
|
2815
3043
|
const scope = {
|
|
2816
3044
|
cwd,
|
|
2817
3045
|
root,
|
|
2818
|
-
packages
|
|
3046
|
+
packages,
|
|
3047
|
+
judged
|
|
2819
3048
|
};
|
|
2820
3049
|
const contributions = entries.map((entry) => ({
|
|
2821
3050
|
...entry,
|
|
@@ -2858,13 +3087,13 @@ async function runImportChecks(input) {
|
|
|
2858
3087
|
return new Map(contributions.map(({ spec }) => [spec.name, spec.report(found.get(spec.name) ?? [], scope)]));
|
|
2859
3088
|
}
|
|
2860
3089
|
/**
|
|
2861
|
-
* The report of a check whose every rule forbids one package importing another: one violation per finding, at the importing file, ordered by file and message.
|
|
3090
|
+
* The report of a check whose every rule forbids one package importing another: one violation per finding the scope judges, at the importing file, ordered by file and message.
|
|
2862
3091
|
*/
|
|
2863
3092
|
function edgeReport(spec) {
|
|
2864
|
-
return (findings, { cwd, root, packages }) => findings.
|
|
3093
|
+
return (findings, { cwd, root, packages, judged }) => findings.flatMap((finding) => {
|
|
2865
3094
|
const from = packageOfPath(packages, finding.from);
|
|
2866
3095
|
const to = packageOfPath(packages, finding.to);
|
|
2867
|
-
return {
|
|
3096
|
+
return judged(from, to) ? [{
|
|
2868
3097
|
code: `${spec.name}/${spec.reason}`,
|
|
2869
3098
|
message: spec.message({
|
|
2870
3099
|
from,
|
|
@@ -2873,7 +3102,7 @@ function edgeReport(spec) {
|
|
|
2873
3102
|
toFile: finding.to
|
|
2874
3103
|
}),
|
|
2875
3104
|
file: relativePosix(cwd, join(root, finding.from))
|
|
2876
|
-
};
|
|
3105
|
+
}] : [];
|
|
2877
3106
|
}).sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
|
|
2878
3107
|
}
|
|
2879
3108
|
/**
|
|
@@ -3594,6 +3823,25 @@ function online(spec) {
|
|
|
3594
3823
|
}
|
|
3595
3824
|
};
|
|
3596
3825
|
}
|
|
3826
|
+
function settingsAware(spec) {
|
|
3827
|
+
return {
|
|
3828
|
+
name: spec.name,
|
|
3829
|
+
description: spec.description,
|
|
3830
|
+
requiresLayout: false,
|
|
3831
|
+
requiresGitHub: false,
|
|
3832
|
+
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
3833
|
+
run: async ({ cwd, checks, configFiles, github }) => {
|
|
3834
|
+
const options = spec.select(checks);
|
|
3835
|
+
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
3836
|
+
return spec.check({
|
|
3837
|
+
cwd,
|
|
3838
|
+
options,
|
|
3839
|
+
...github === void 0 ? {} : { github },
|
|
3840
|
+
...configFiles === void 0 ? {} : { configFiles }
|
|
3841
|
+
});
|
|
3842
|
+
}
|
|
3843
|
+
};
|
|
3844
|
+
}
|
|
3597
3845
|
function importCheck(spec) {
|
|
3598
3846
|
return {
|
|
3599
3847
|
name: spec.name,
|
|
@@ -3755,7 +4003,7 @@ const registry = {
|
|
|
3755
4003
|
select: (checks) => enabledOptions(checks["workflow-update-bot-cooldown"]),
|
|
3756
4004
|
check: workflowUpdateBotCooldown
|
|
3757
4005
|
}),
|
|
3758
|
-
"workflow-merge-group":
|
|
4006
|
+
"workflow-merge-group": settingsAware({
|
|
3759
4007
|
name: "workflow-merge-group",
|
|
3760
4008
|
description: "A workflow that runs for pull requests also runs for the merge queue",
|
|
3761
4009
|
select: (checks) => enabledOptions(checks["workflow-merge-group"]),
|
|
@@ -3951,7 +4199,10 @@ const checks = z.strictObject({
|
|
|
3951
4199
|
dependabot: z.exactOptional(path),
|
|
3952
4200
|
renovate: z.exactOptional(path)
|
|
3953
4201
|
}))),
|
|
3954
|
-
"workflow-merge-group": z.exactOptional(enabled(z.strictObject(
|
|
4202
|
+
"workflow-merge-group": z.exactOptional(enabled(z.strictObject({
|
|
4203
|
+
...selection,
|
|
4204
|
+
...settings
|
|
4205
|
+
}))),
|
|
3955
4206
|
"workflow-action-pinning": z.exactOptional(enabled(z.strictObject({
|
|
3956
4207
|
...selection,
|
|
3957
4208
|
thirdParty: z.exactOptional(pinningLevel),
|